Spring Cloud Contract enables Consumer-Driven Contracts (CDC) testing for microservices. This guide covers comprehensive implementation for both consumer and provider sides.
Setup and Dependencies
1. Provider Dependencies (Maven)
<!-- Provider POM --> <project> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.0</version> </parent> <properties> <spring-cloud.version>2022.0.3</spring-cloud.version> <spring-cloud-contract.version>4.0.3</spring-cloud-contract.version> <maven-surefire-plugin.version>3.0.0</maven-surefire-plugin.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- Spring Boot Starters --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <!-- Spring Cloud Contract --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-verifier</artifactId> <scope>test</scope> </dependency> <!-- Testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-wiremock</artifactId> <scope>test</scope> </dependency> <!-- Database --> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>test</scope> </dependency> <dependency> <groupId>org.postgresql</groupId> <artifactId>postgresql</artifactId> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> <!-- Spring Cloud Contract Maven Plugin --> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>${spring-cloud-contract.version}</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> <baseClassForTests>com.company.provider.BaseContractTest</baseClassForTests> <packageWithBaseClasses>com.company.provider</packageWithBaseClasses> </configuration> </plugin> </plugins> </build> </project> 2. Consumer Dependencies (Maven)
<!-- Consumer POM --> <project> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.1.0</version> </parent> <properties> <spring-cloud.version>2022.0.3</spring-cloud.version> <spring-cloud-contract.version>4.0.3</spring-cloud-contract.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-dependencies</artifactId> <version>${spring-cloud.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <!-- Spring Boot Starters --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <!-- Spring Cloud Contract Stub Runner --> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-contract-stub-runner</artifactId> <scope>test</scope> </dependency> <!-- WebClient for HTTP calls --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-webflux</artifactId> </dependency> <!-- Testing --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> </project> Provider Implementation
1. Provider Domain Models
package com.company.provider.model; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.persistence.*; import jakarta.validation.constraints.*; import java.time.LocalDateTime; import java.util.List; @Entity @Table(name = "users") @JsonInclude(JsonInclude.Include.NON_NULL) public class User { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "Email is required") @Email(message = "Invalid email format") @Column(unique = true, nullable = false) private String email; @NotBlank(message = "First name is required") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") private String firstName; @NotBlank(message = "Last name is required") @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; @Enumerated(EnumType.STRING) private UserStatus status = UserStatus.ACTIVE; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime createdAt; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime updatedAt; @OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY) private List<UserPreference> preferences; // Constructors public User() { this.createdAt = LocalDateTime.now(); this.updatedAt = LocalDateTime.now(); } public User(String email, String firstName, String lastName) { this(); this.email = email; this.firstName = firstName; this.lastName = lastName; } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public UserStatus getStatus() { return status; } public void setStatus(UserStatus status) { this.status = status; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public List<UserPreference> getPreferences() { return preferences; } public void setPreferences(List<UserPreference> preferences) { this.preferences = preferences; } @PreUpdate public void preUpdate() { this.updatedAt = LocalDateTime.now(); } public enum UserStatus { ACTIVE, INACTIVE, SUSPENDED } } @Entity @Table(name = "user_preferences") @JsonInclude(JsonInclude.Include.NON_NULL) public class UserPreference { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @NotBlank(message = "Preference key is required") private String key; @NotBlank(message = "Preference value is required") private String value; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id") private User user; // Constructors, Getters, and Setters public UserPreference() {} public UserPreference(String key, String value) { this.key = key; this.value = value; } public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getKey() { return key; } public void setKey(String key) { this.key = key; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } public User getUser() { return user; } public void setUser(User user) { this.user = user; } } 2. Provider DTOs
package com.company.provider.dto; import com.fasterxml.jackson.annotation.JsonFormat; import com.fasterxml.jackson.annotation.JsonInclude; import jakarta.validation.constraints.*; import java.time.LocalDateTime; import java.util.List; public class UserDto { public static class CreateUserRequest { @NotBlank(message = "Email is required") @Email(message = "Invalid email format") private String email; @NotBlank(message = "First name is required") @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") private String firstName; @NotBlank(message = "Last name is required") @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; private List<PreferenceRequest> preferences; // Getters and Setters public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public List<PreferenceRequest> getPreferences() { return preferences; } public void setPreferences(List<PreferenceRequest> preferences) { this.preferences = preferences; } } public static class UpdateUserRequest { @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters") private String firstName; @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters") private String lastName; // Getters and Setters public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } } public static class UserResponse { private Long id; private String email; private String firstName; private String lastName; private String status; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime createdAt; @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") private LocalDateTime updatedAt; private List<PreferenceResponse> preferences; // Constructors public UserResponse() {} public UserResponse(Long id, String email, String firstName, String lastName, String status, LocalDateTime createdAt, LocalDateTime updatedAt) { this.id = id; this.email = email; this.firstName = firstName; this.lastName = lastName; this.status = status; this.createdAt = createdAt; this.updatedAt = updatedAt; } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public LocalDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; } public LocalDateTime getUpdatedAt() { return updatedAt; } public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; } public List<PreferenceResponse> getPreferences() { return preferences; } public void setPreferences(List<PreferenceResponse> preferences) { this.preferences = preferences; } } public static class PreferenceRequest { @NotBlank(message = "Preference key is required") private String key; @NotBlank(message = "Preference value is required") private String value; // Getters and Setters public String getKey() { return key; } public void setKey(String key) { this.key = key; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } } public static class PreferenceResponse { private Long id; private String key; private String value; // Constructors public PreferenceResponse() {} public PreferenceResponse(Long id, String key, String value) { this.id = id; this.key = key; this.value = value; } // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getKey() { return key; } public void setKey(String key) { this.key = key; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } } public static class ErrorResponse { private String timestamp; private int status; private String error; private String message; private String path; // Constructors public ErrorResponse() { this.timestamp = LocalDateTime.now().toString(); } public ErrorResponse(int status, String error, String message, String path) { this(); this.status = status; this.error = error; this.message = message; this.path = path; } // Getters and Setters public String getTimestamp() { return timestamp; } public void setTimestamp(String timestamp) { this.timestamp = timestamp; } public int getStatus() { return status; } public void setStatus(int status) { this.status = status; } public String getError() { return error; } public void setError(String error) { this.error = error; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getPath() { return path; } public void setPath(String path) { this.path = path; } } } 3. Provider Controller
package com.company.provider.controller; import com.company.provider.dto.UserDto; import com.company.provider.model.User; import com.company.provider.service.UserService; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import java.util.List; @RestController @RequestMapping("/api/v1/users") public class UserController { private final UserService userService; @Autowired public UserController(UserService userService) { this.userService = userService; } @PostMapping public ResponseEntity<UserDto.UserResponse> createUser( @Valid @RequestBody UserDto.CreateUserRequest request) { UserDto.UserResponse user = userService.createUser(request); return ResponseEntity.status(HttpStatus.CREATED).body(user); } @GetMapping("/{id}") public ResponseEntity<UserDto.UserResponse> getUser(@PathVariable Long id) { UserDto.UserResponse user = userService.getUserById(id); return ResponseEntity.ok(user); } @GetMapping public ResponseEntity<Page<UserDto.UserResponse>> getUsers( @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(defaultValue = "createdAt") String sort, @RequestParam(defaultValue = "desc") String direction) { Sort.Direction sortDirection = "desc".equalsIgnoreCase(direction) ? Sort.Direction.DESC : Sort.Direction.ASC; Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort)); Page<UserDto.UserResponse> users = userService.getUsers(pageable); return ResponseEntity.ok(users); } @GetMapping("/search") public ResponseEntity<List<UserDto.UserResponse>> searchUsers( @RequestParam String email) { List<UserDto.UserResponse> users = userService.searchUsersByEmail(email); return ResponseEntity.ok(users); } @PutMapping("/{id}") public ResponseEntity<UserDto.UserResponse> updateUser( @PathVariable Long id, @Valid @RequestBody UserDto.UpdateUserRequest request) { UserDto.UserResponse user = userService.updateUser(id, request); return ResponseEntity.ok(user); } @DeleteMapping("/{id}") public ResponseEntity<Void> deleteUser(@PathVariable Long id) { userService.deleteUser(id); return ResponseEntity.noContent().build(); } @PatchMapping("/{id}/deactivate") public ResponseEntity<UserDto.UserResponse> deactivateUser(@PathVariable Long id) { UserDto.UserResponse user = userService.deactivateUser(id); return ResponseEntity.ok(user); } @PatchMapping("/{id}/activate") public ResponseEntity<UserDto.UserResponse> activateUser(@PathVariable Long id) { UserDto.UserResponse user = userService.activateUser(id); return ResponseEntity.ok(user); } @GetMapping("/{id}/preferences") public ResponseEntity<List<UserDto.PreferenceResponse>> getUserPreferences(@PathVariable Long id) { List<UserDto.PreferenceResponse> preferences = userService.getUserPreferences(id); return ResponseEntity.ok(preferences); } @PostMapping("/{id}/preferences") public ResponseEntity<UserDto.PreferenceResponse> addUserPreference( @PathVariable Long id, @Valid @RequestBody UserDto.PreferenceRequest request) { UserDto.PreferenceResponse preference = userService.addUserPreference(id, request); return ResponseEntity.status(HttpStatus.CREATED).body(preference); } } 4. Provider Service
package com.company.provider.service; import com.company.provider.dto.UserDto; import com.company.provider.model.User; import com.company.provider.model.UserPreference; import com.company.provider.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.stream.Collectors; @Service @Transactional public class UserService { private final UserRepository userRepository; @Autowired public UserService(UserRepository userRepository) { this.userRepository = userRepository; } public UserDto.UserResponse createUser(UserDto.CreateUserRequest request) { // Check if user already exists if (userRepository.existsByEmail(request.getEmail())) { throw new UserAlreadyExistsException("User with email " + request.getEmail() + " already exists"); } // Create user User user = new User(request.getEmail(), request.getFirstName(), request.getLastName()); // Add preferences if provided if (request.getPreferences() != null) { List<UserPreference> preferences = request.getPreferences().stream() .map(pref -> new UserPreference(pref.getKey(), pref.getValue())) .collect(Collectors.toList()); preferences.forEach(pref -> pref.setUser(user)); user.setPreferences(preferences); } User savedUser = userRepository.save(user); return mapToUserResponse(savedUser); } public UserDto.UserResponse getUserById(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); return mapToUserResponse(user); } public Page<UserDto.UserResponse> getUsers(Pageable pageable) { return userRepository.findAll(pageable) .map(this::mapToUserResponse); } public List<UserDto.UserResponse> searchUsersByEmail(String email) { return userRepository.findByEmailContainingIgnoreCase(email).stream() .map(this::mapToUserResponse) .collect(Collectors.toList()); } public UserDto.UserResponse updateUser(Long id, UserDto.UpdateUserRequest request) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); if (request.getFirstName() != null) { user.setFirstName(request.getFirstName()); } if (request.getLastName() != null) { user.setLastName(request.getLastName()); } User updatedUser = userRepository.save(user); return mapToUserResponse(updatedUser); } public void deleteUser(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); userRepository.delete(user); } public UserDto.UserResponse deactivateUser(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); user.setStatus(User.UserStatus.INACTIVE); User updatedUser = userRepository.save(user); return mapToUserResponse(updatedUser); } public UserDto.UserResponse activateUser(Long id) { User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + id)); user.setStatus(User.UserStatus.ACTIVE); User updatedUser = userRepository.save(user); return mapToUserResponse(updatedUser); } public List<UserDto.PreferenceResponse> getUserPreferences(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + userId)); return user.getPreferences().stream() .map(this::mapToPreferenceResponse) .collect(Collectors.toList()); } public UserDto.PreferenceResponse addUserPreference(Long userId, UserDto.PreferenceRequest request) { User user = userRepository.findById(userId) .orElseThrow(() -> new UserNotFoundException("User not found with id: " + userId)); UserPreference preference = new UserPreference(request.getKey(), request.getValue()); preference.setUser(user); user.getPreferences().add(preference); UserPreference savedPreference = userRepository.save(user).getPreferences() .stream() .filter(p -> p.getKey().equals(request.getKey())) .findFirst() .orElseThrow(() -> new RuntimeException("Failed to save preference")); return mapToPreferenceResponse(savedPreference); } private UserDto.UserResponse mapToUserResponse(User user) { UserDto.UserResponse response = new UserDto.UserResponse( user.getId(), user.getEmail(), user.getFirstName(), user.getLastName(), user.getStatus().name(), user.getCreatedAt(), user.getUpdatedAt() ); if (user.getPreferences() != null) { List<UserDto.PreferenceResponse> preferences = user.getPreferences().stream() .map(this::mapToPreferenceResponse) .collect(Collectors.toList()); response.setPreferences(preferences); } return response; } private UserDto.PreferenceResponse mapToPreferenceResponse(UserPreference preference) { return new UserDto.PreferenceResponse( preference.getId(), preference.getKey(), preference.getValue() ); } // Custom Exceptions public static class UserNotFoundException extends RuntimeException { public UserNotFoundException(String message) { super(message); } } public static class UserAlreadyExistsException extends RuntimeException { public UserAlreadyExistsException(String message) { super(message); } } } Contract Definitions
1. Contract Files Structure
src/test/resources/contracts/ ├── users/ │ ├── shouldCreateUser.groovy │ ├── shouldReturnUser.groovy │ ├── shouldReturnUserNotFound.groovy │ ├── shouldReturnAllUsers.groovy │ ├── shouldUpdateUser.groovy │ ├── shouldDeleteUser.groovy │ └── shouldDeactivateUser.groovy └── preferences/ ├── shouldGetUserPreferences.groovy └── shouldAddUserPreference.groovy
2. User Contracts
// shouldCreateUser.groovy package contracts.users import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should create a new user" name "create_user" label "create_user_success" request { method POST() url "/api/v1/users" headers { contentType(applicationJson()) } body([ email: "[email protected]", firstName: "John", lastName: "Doe", preferences: [ [ key: "theme", value: "dark" ], [ key: "language", value: "en" ] ] ]) } response { status CREATED() headers { contentType(applicationJson()) } body([ id: 1, email: "[email protected]", firstName: "John", lastName: "Doe", status: "ACTIVE", createdAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))), updatedAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))), preferences: [ [ id: 1, key: "theme", value: "dark" ], [ id: 2, key: "language", value: "en" ] ] ]) } } // shouldReturnUser.groovy package contracts.users import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should return user by ID" name "get_user" label "get_user_success" request { method GET() url $(consumer("/api/v1/users/1"), producer(regex('/api/v1/users/[0-9]+'))) } response { status OK() headers { contentType(applicationJson()) } body([ id: 1, email: "[email protected]", firstName: "John", lastName: "Doe", status: "ACTIVE", createdAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))), updatedAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))) ]) } } // shouldReturnUserNotFound.groovy package contracts.users import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should return 404 when user not found" name "get_user_not_found" label "get_user_not_found" request { method GET() url $(consumer("/api/v1/users/999"), producer(regex('/api/v1/users/[0-9]+'))) } response { status NOT_FOUND() headers { contentType(applicationJson()) } body([ timestamp: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))), status: 404, error: "Not Found", message: "User not found with id: 999", path: $(consumer("/api/v1/users/999"), producer(regex('/api/v1/users/[0-9]+'))) ]) } } // shouldReturnAllUsers.groovy package contracts.users import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should return paginated users" name "get_users" label "get_users_success" request { method GET() url "/api/v1/users" queryParameters { parameter "page": "0" parameter "size": "2" parameter "sort": "createdAt" parameter "direction": "desc" } } response { status OK() headers { contentType(applicationJson()) } body([ content: [ [ id: 1, email: "[email protected]", firstName: "John", lastName: "Doe", status: "ACTIVE", createdAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))), updatedAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))) ], [ id: 2, email: "[email protected]", firstName: "Jane", lastName: "Smith", status: "ACTIVE", createdAt: $(consumer("2023-01-01T09:00:00"), producer(regex(iso8601WithOffset()))), updatedAt: $(consumer("2023-01-01T09:00:00"), producer(regex(iso8601WithOffset()))) ] ], totalElements: 2, totalPages: 1, size: 2, number: 0, first: true, last: true, numberOfElements: 2, empty: false ]) bodyMatchers { jsonPath('$.content', byType { minOccurrence(1) }) } } } // shouldUpdateUser.groovy package contracts.users import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should update user" name "update_user" label "update_user_success" request { method PUT() url $(consumer("/api/v1/users/1"), producer(regex('/api/v1/users/[0-9]+'))) headers { contentType(applicationJson()) } body([ firstName: "Johnny", lastName: "Doe Jr." ]) } response { status OK() headers { contentType(applicationJson()) } body([ id: 1, email: "[email protected]", firstName: "Johnny", lastName: "Doe Jr.", status: "ACTIVE", createdAt: $(consumer("2023-01-01T10:00:00"), producer(regex(iso8601WithOffset()))), updatedAt: $(consumer("2023-01-01T11:00:00"), producer(regex(iso8601WithOffset()))) ]) } } 3. Preference Contracts
// shouldGetUserPreferences.groovy package contracts.preferences import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should return user preferences" name "get_user_preferences" label "get_user_preferences_success" request { method GET() url $(consumer("/api/v1/users/1/preferences"), producer(regex('/api/v1/users/[0-9]+/preferences'))) } response { status OK() headers { contentType(applicationJson()) } body([ [ id: 1, key: "theme", value: "dark" ], [ id: 2, key: "language", value: "en" ] ]) } } // shouldAddUserPreference.groovy package contracts.preferences import org.springframework.cloud.contract.spec.Contract Contract.make { description "Should add user preference" name "add_user_preference" label "add_user_preference_success" request { method POST() url $(consumer("/api/v1/users/1/preferences"), producer(regex('/api/v1/users/[0-9]+/preferences'))) headers { contentType(applicationJson()) } body([ key: "notifications", value: "enabled" ]) } response { status CREATED() headers { contentType(applicationJson()) } body([ id: 3, key: "notifications", value: "enabled" ]) } } Provider Test Base Class
package com.company.provider; import com.company.provider.model.User; import com.company.provider.model.UserPreference; import com.company.provider.repository.UserRepository; import io.restassured.module.mockmvc.RestAssuredMockMvc; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.verifier.messaging.boot.AutoConfigureMessageVerifier; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ActiveProfiles; import org.springframework.web.context.WebApplicationContext; import java.time.LocalDateTime; import java.util.Arrays; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK) @AutoConfigureMessageVerifier @DirtiesContext @ActiveProfiles("test") public abstract class BaseContractTest { @Autowired private WebApplicationContext context; @Autowired private UserRepository userRepository; @BeforeEach public void setup() { RestAssuredMockMvc.webAppContextSetup(context); setupTestData(); } private void setupTestData() { userRepository.deleteAll(); // Create test users User user1 = new User("[email protected]", "John", "Doe"); user1.setId(1L); user1.setCreatedAt(LocalDateTime.of(2023, 1, 1, 10, 0, 0)); user1.setUpdatedAt(LocalDateTime.of(2023, 1, 1, 10, 0, 0)); UserPreference pref1 = new UserPreference("theme", "dark"); pref1.setId(1L); pref1.setUser(user1); UserPreference pref2 = new UserPreference("language", "en"); pref2.setId(2L); pref2.setUser(user1); user1.setPreferences(Arrays.asList(pref1, pref2)); userRepository.save(user1); User user2 = new User("[email protected]", "Jane", "Smith"); user2.setId(2L); user2.setCreatedAt(LocalDateTime.of(2023, 1, 1, 9, 0, 0)); user2.setUpdatedAt(LocalDateTime.of(2023, 1, 1, 9, 0, 0)); userRepository.save(user2); } } Consumer Implementation
1. Consumer Client
package com.company.consumer.client; import com.company.consumer.config.UserServiceClientConfig; import com.company.consumer.dto.UserResponse; import org.springframework.cloud.openfeign.FeignClient; import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; import java.util.List; @FeignClient( name = "user-service", url = "${user.service.url:http://localhost:8080}", configuration = UserServiceClientConfig.class ) public interface UserServiceClient { @PostMapping("/api/v1/users") UserResponse createUser(@RequestBody CreateUserRequest request); @GetMapping("/api/v1/users/{id}") UserResponse getUser(@PathVariable("id") Long id); @GetMapping("/api/v1/users") Page<UserResponse> getUsers( @RequestParam(value = "page", defaultValue = "0") int page, @RequestParam(value = "size", defaultValue = "20") int size, @RequestParam(value = "sort", defaultValue = "createdAt") String sort, @RequestParam(value = "direction", defaultValue = "desc") String direction); @GetMapping("/api/v1/users/search") List<UserResponse> searchUsers(@RequestParam("email") String email); @PutMapping("/api/v1/users/{id}") UserResponse updateUser(@PathVariable("id") Long id, @RequestBody UpdateUserRequest request); @DeleteMapping("/api/v1/users/{id}") void deleteUser(@PathVariable("id") Long id); @PatchMapping("/api/v1/users/{id}/deactivate") UserResponse deactivateUser(@PathVariable("id") Long id); @PatchMapping("/api/v1/users/{id}/activate") UserResponse activateUser(@PathVariable("id") Long id); @GetMapping("/api/v1/users/{id}/preferences") List<PreferenceResponse> getUserPreferences(@PathVariable("id") Long id); @PostMapping("/api/v1/users/{id}/preferences") PreferenceResponse addUserPreference(@PathVariable("id") Long id, @RequestBody PreferenceRequest request); // Request/Response DTOs class CreateUserRequest { private String email; private String firstName; private String lastName; private List<PreferenceRequest> preferences; // Constructors public CreateUserRequest() {} public CreateUserRequest(String email, String firstName, String lastName) { this.email = email; this.firstName = firstName; this.lastName = lastName; } // Getters and Setters public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public List<PreferenceRequest> getPreferences() { return preferences; } public void setPreferences(List<PreferenceRequest> preferences) { this.preferences = preferences; } } class UpdateUserRequest { private String firstName; private String lastName; // Getters and Setters public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } } class UserResponse { private Long id; private String email; private String firstName; private String lastName; private String status; private String createdAt; private String updatedAt; private List<PreferenceResponse> preferences; // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getEmail() { return email; } public void setEmail(String email) { this.email = email; } public String getFirstName() { return firstName; } public void setFirstName(String firstName) { this.firstName = firstName; } public String getLastName() { return lastName; } public void setLastName(String lastName) { this.lastName = lastName; } public String getStatus() { return status; } public void setStatus(String status) { this.status = status; } public String getCreatedAt() { return createdAt; } public void setCreatedAt(String createdAt) { this.createdAt = createdAt; } public String getUpdatedAt() { return updatedAt; } public void setUpdatedAt(String updatedAt) { this.updatedAt = updatedAt; } public List<PreferenceResponse> getPreferences() { return preferences; } public void setPreferences(List<PreferenceResponse> preferences) { this.preferences = preferences; } } class PreferenceRequest { private String key; private String value; // Constructors public PreferenceRequest() {} public PreferenceRequest(String key, String value) { this.key = key; this.value = value; } // Getters and Setters public String getKey() { return key; } public void setKey(String key) { this.key = key; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } } class PreferenceResponse { private Long id; private String key; private String value; // Getters and Setters public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getKey() { return key; } public void setKey(String key) { this.key = key; } public String getValue() { return value; } public void setValue(String value) { this.value = value; } } } 2. Consumer Client Configuration
package com.company.consumer.config; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import feign.Logger; import feign.RequestInterceptor; import feign.codec.ErrorDecoder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class UserServiceClientConfig { @Bean public Logger.Level feignLoggerLevel() { return Logger.Level.FULL; } @Bean public RequestInterceptor requestInterceptor() { return template -> { template.header("Content-Type", "application/json"); template.header("Accept", "application/json"); }; } @Bean public ErrorDecoder errorDecoder() { return new UserServiceErrorDecoder(); } @Bean public ObjectMapper objectMapper() { ObjectMapper mapper = new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } } 3. Consumer Error Handling
package com.company.consumer.config; import com.company.consumer.exception.UserServiceException; import feign.Response; import feign.codec.ErrorDecoder; import org.springframework.http.HttpStatus; import java.io.IOException; import java.io.InputStream; public class UserServiceErrorDecoder implements ErrorDecoder { private final ObjectMapper objectMapper = new ObjectMapper(); @Override public Exception decode(String methodKey, Response response) { try { InputStream responseBody = response.body().asInputStream(); ErrorResponse errorResponse = objectMapper.readValue(responseBody, ErrorResponse.class); return new UserServiceException( errorResponse.getMessage(), HttpStatus.valueOf(response.status()) ); } catch (IOException e) { return new UserServiceException( "Error communicating with user service", HttpStatus.INTERNAL_SERVER_ERROR ); } } static class ErrorResponse { private String timestamp; private int status; private String error; private String message; private String path; // Getters and Setters public String getTimestamp() { return timestamp; } public void setTimestamp(String timestamp) { this.timestamp = timestamp; } public int getStatus() { return status; } public void setStatus(int status) { this.status = status; } public String getError() { return error; } public void setError(String error) { this.error = error; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public String getPath() { return path; } public void setPath(String path) { this.path = path; } } } package com.company.consumer.exception; import org.springframework.http.HttpStatus; public class UserServiceException extends RuntimeException { private final HttpStatus status; public UserServiceException(String message, HttpStatus status) { super(message); this.status = status; } public HttpStatus getStatus() { return status; } } 4. Consumer Service
package com.company.consumer.service; import com.company.consumer.client.UserServiceClient; import com.company.consumer.exception.UserServiceException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import java.util.List; @Service public class UserService { private final UserServiceClient userServiceClient; @Autowired public UserService(UserServiceClient userServiceClient) { this.userServiceClient = userServiceClient; } public UserServiceClient.UserResponse createUser(UserServiceClient.CreateUserRequest request) { try { return userServiceClient.createUser(request); } catch (Exception e) { throw new UserServiceException("Failed to create user: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } } public UserServiceClient.UserResponse getUser(Long id) { try { return userServiceClient.getUser(id); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + id, HttpStatus.NOT_FOUND); } throw e; } } public Page<UserServiceClient.UserResponse> getUsers(int page, int size, String sort, String direction) { try { return userServiceClient.getUsers(page, size, sort, direction); } catch (Exception e) { throw new UserServiceException("Failed to get users: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } } public List<UserServiceClient.UserResponse> searchUsers(String email) { try { return userServiceClient.searchUsers(email); } catch (Exception e) { throw new UserServiceException("Failed to search users: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); } } public UserServiceClient.UserResponse updateUser(Long id, UserServiceClient.UpdateUserRequest request) { try { return userServiceClient.updateUser(id, request); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + id, HttpStatus.NOT_FOUND); } throw e; } } public void deleteUser(Long id) { try { userServiceClient.deleteUser(id); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + id, HttpStatus.NOT_FOUND); } throw e; } } public UserServiceClient.UserResponse deactivateUser(Long id) { try { return userServiceClient.deactivateUser(id); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + id, HttpStatus.NOT_FOUND); } throw e; } } public UserServiceClient.UserResponse activateUser(Long id) { try { return userServiceClient.activateUser(id); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + id, HttpStatus.NOT_FOUND); } throw e; } } public List<UserServiceClient.PreferenceResponse> getUserPreferences(Long userId) { try { return userServiceClient.getUserPreferences(userId); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + userId, HttpStatus.NOT_FOUND); } throw e; } } public UserServiceClient.PreferenceResponse addUserPreference(Long userId, UserServiceClient.PreferenceRequest request) { try { return userServiceClient.addUserPreference(userId, request); } catch (UserServiceException e) { if (e.getStatus() == HttpStatus.NOT_FOUND) { throw new UserServiceException("User not found with id: " + userId, HttpStatus.NOT_FOUND); } throw e; } } } Consumer Contract Tests
1. Consumer Base Test Class
package com.company.consumer.contract; import com.company.consumer.client.UserServiceClient; import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner; import org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties; import org.springframework.test.context.ActiveProfiles; @SpringBootTest @AutoConfigureStubRunner( ids = "com.company.provider:user-service:+:stubs:8080", stubsMode = StubRunnerProperties.StubsMode.LOCAL ) @ActiveProfiles("test") public abstract class BaseContractTest { @Autowired protected UserServiceClient userServiceClient; @BeforeEach void setUp() { // Common setup for all contract tests } } 2. Consumer Contract Tests
package com.company.consumer.contract; import com.company.consumer.client.UserServiceClient; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.domain.Page; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; class UserServiceContractTest extends BaseContractTest { @Autowired private UserServiceClient userServiceClient; @Test void shouldCreateUser() { // Given UserServiceClient.CreateUserRequest request = new UserServiceClient.CreateUserRequest( "[email protected]", "John", "Doe" ); // When UserServiceClient.UserResponse response = userServiceClient.createUser(request); // Then assertNotNull(response); assertThat(response.getId()).isEqualTo(1L); assertThat(response.getEmail()).isEqualTo("[email protected]"); assertThat(response.getFirstName()).isEqualTo("John"); assertThat(response.getLastName()).isEqualTo("Doe"); assertThat(response.getStatus()).isEqualTo("ACTIVE"); assertThat(response.getPreferences()).hasSize(2); } @Test void shouldGetUser() { // When UserServiceClient.UserResponse response = userServiceClient.getUser(1L); // Then assertNotNull(response); assertThat(response.getId()).isEqualTo(1L); assertThat(response.getEmail()).isEqualTo("[email protected]"); assertThat(response.getFirstName()).isEqualTo("John"); assertThat(response.getLastName()).isEqualTo("Doe"); assertThat(response.getStatus()).isEqualTo("ACTIVE"); } @Test void shouldGetAllUsers() { // When Page<UserServiceClient.UserResponse> response = userServiceClient.getUsers(0, 2, "createdAt", "desc"); // Then assertNotNull(response); assertThat(response.getContent()).hasSize(2); assertThat(response.getTotalElements()).isEqualTo(2); assertThat(response.getTotalPages()).isEqualTo(1); UserServiceClient.UserResponse firstUser = response.getContent().get(0); assertThat(firstUser.getId()).isEqualTo(1L); assertThat(firstUser.getEmail()).isEqualTo("[email protected]"); } @Test void shouldUpdateUser() { // Given UserServiceClient.UpdateUserRequest request = new UserServiceClient.UpdateUserRequest(); request.setFirstName("Johnny"); request.setLastName("Doe Jr."); // When UserServiceClient.UserResponse response = userServiceClient.updateUser(1L, request); // Then assertNotNull(response); assertThat(response.getId()).isEqualTo(1L); assertThat(response.getFirstName()).isEqualTo("Johnny"); assertThat(response.getLastName()).isEqualTo("Doe Jr."); } @Test void shouldDeleteUser() { // When & Then - Should not throw exception assertDoesNotThrow(() -> userServiceClient.deleteUser(1L)); } @Test void shouldDeactivateUser() { // When UserServiceClient.UserResponse response = userServiceClient.deactivateUser(1L); // Then assertNotNull(response); assertThat(response.getId()).isEqualTo(1L); assertThat(response.getStatus()).isEqualTo("INACTIVE"); } @Test void shouldGetUserPreferences() { // When List<UserServiceClient.PreferenceResponse> preferences = userServiceClient.getUserPreferences(1L); // Then assertNotNull(preferences); assertThat(preferences).hasSize(2); UserServiceClient.PreferenceResponse firstPreference = preferences.get(0); assertThat(firstPreference.getId()).isEqualTo(1L); assertThat(firstPreference.getKey()).isEqualTo("theme"); assertThat(firstPreference.getValue()).isEqualTo("dark"); } @Test void shouldAddUserPreference() { // Given UserServiceClient.PreferenceRequest request = new UserServiceClient.PreferenceRequest("notifications", "enabled"); // When UserServiceClient.PreferenceResponse response = userServiceClient.addUserPreference(1L, request); // Then assertNotNull(response); assertThat(response.getId()).isEqualTo(3L); assertThat(response.getKey()).isEqualTo("notifications"); assertThat(response.getValue()).isEqualTo("enabled"); } } Build and Deployment Configuration
1. Provider Maven Configuration
<build> <plugins> <plugin> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-contract-maven-plugin</artifactId> <version>${spring-cloud-contract.version}</version> <extensions>true</extensions> <configuration> <testFramework>JUNIT5</testFramework> <baseClassForTests>com.company.provider.BaseContractTest</baseClassForTests> <packageWithBaseClasses>com.company.provider</packageWithBaseClasses> <basePackageForTests>com.company.provider.contract</basePackageForTests> </configuration> <executions> <execution> <goals> <goal>convert</goal> <goal>generateTests</goal> <goal>generateStubs</goal> </goals> </execution> </executions> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-surefire-plugin</artifactId> <version>${maven-surefire-plugin.version}</version> <configuration> <includes> <include>**/*Test.java</include> <include>**/*ContractTest.java</include> </includes> </configuration> </plugin> </plugins> </build> 2. Stub Repository Configuration
# application.yml for Provider spring: application: name: user-service cloud: contract: verifier: # Where the generated tests will be created generated-test-sources-directory: src/test/java/contracts # Where the contracts are stored contractsDslDir: src/test/resources/contracts # Base package for generated tests basePackageForTests: com.company.provider.contract # Stub repository configuration repository: cache: download: stubs-root: http://nexus.company.com/repository/maven-releases stubrunner: # Where to publish stubs stubs-mode: remote # Repository configuration for stubs repository-root: http://nexus.company.com/repository/maven-releases ids: - com.company.provider:user-service:+:stubs:8080 # Test profile --- spring: profiles: test datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE driver-class-name: org.h2.Driver username: sa password: jpa: database-platform: org.hibernate.dialect.H2Dialect hibernate: ddl-auto: create-drop show-sql: true h2: console: enabled: true
3. CI/CD Pipeline Script
// Jenkinsfile for Provider pipeline { agent any stages { stage('Build') { steps { sh 'mvn clean compile' } } stage('Test') { steps { sh 'mvn test' } } stage('Contract Tests') { steps { sh 'mvn spring-cloud-contract:generateTests' sh 'mvn test -Dtest=**/*ContractTest' } } stage('Generate Stubs') { steps { sh 'mvn spring-cloud-contract:generateStubs' } } stage('Publish Stubs') { steps { sh 'mvn deploy -DskipTests' } } stage('Integration Tests') { steps { sh 'mvn verify -Pintegration' } } } post { always { junit '**/target/surefire-reports/*.xml' } } } Summary
This Spring Cloud Contract implementation provides:
- Consumer-Driven Contracts: Consumers define expected API behavior
- Provider Verification: Automated testing against contract specifications
- Stub Generation: Realistic API stubs for consumer testing
- API Evolution: Safe API changes with contract compatibility
Key Benefits:
- Reduced Integration Issues: Catch breaking changes early
- Faster Development: Parallel development with confidence
- Better Collaboration: Clear API expectations between teams
- Automated Testing: Contract validation in CI/CD pipelines
- Documentation: Contracts serve as living API documentation
This comprehensive setup ensures that microservices can evolve independently while maintaining compatibility through automated contract testing.