How the HTTP communication between Spring microservices in the Whoz back-end is implemented
A practical story about defining interfaces, clients, and controllers
Coupling and compatibility issues
Whoz backend is based on a dozen Spring Boot microservices that expose endpoints and call each other.
Several years ago, we had a module called api-client
in our monorepo. We put in there Feign clients and the data transfer objects (DTOs) used for exchanging information.
This became a massive module that all microservices depended on, which led to three main issues:
- Small changes in a service, like adding a property in an endpoint’s response, were triggering the build for all microservices.
- A breaking change in an endpoint contract could lead to runtime errors if
api-client
‘s Feign clients were not updated as well - Business and architecture rules could be easily violated because each microservice could call any other. Indeed, there is a logic where a service A can call a service B, but not the other way around.
The Three-Tier Structure
To solve those issues, we have split our back-end services into three distinct components: xxx-service, xxx-api, and xxx-client.
This separation of concerns ensures modularity, compatibility, and maintainability of our services.
Let’s take an example with our “casting” service.
Casting-Service
The casting-service is the core microservice that implements the actual business logic, handles data persistence, and exposes RESTful endpoints. It is a Spring Boot application that uses controllers to define endpoints and services for business logic.
Casting-API
The casting-api module defines the interfaces for the service endpoints and the data transfer objects (DTOs) used for exchanging information. This module acts as a contract between the service and its clients, ensuring that both sides agree on the communication protocol.
Casting-Client
The casting-client is a library that other services use to communicate with the casting-service. It includes Feign clients that are interfaces mirroring the casting-api endpoints.
Advantages
By implementing the same interface in both the controller within the casting-service and the Feign client in the casting-client, we ensure compatibility and ease of communication.
Services are not tightly bound to each other, allowing for independent development and deployment. Also, each microservice must include the api and client dependency, here casting-api
and casting-client
. So in code review, an architecture mistake is easily spotted because of the wrongly added dependency.
Code snippets
casting-api
package casting.api.project
interface ProjectMatchApi {
fun autocomplete(@Valid projectAutocompleteInput: ProjectAutocompleteInput): List<ProjectAutocompleteOutput>
}
package casting.api.project.resource.input
data class ProjectAutocompleteInput(
@field:NotBlank
val federationId: String,
@field:NotBlank
val input: String,
)
package casting.api.project.resource.output
data class ProjectAutocompleteOutput (
val federationId: String,
val projectId: String,
val score: Float,
)
casting-service
package casting.api.project
import casting.api.project.resource.input.ProjectAutocompleteInput
import casting.api.project.resource.output.ProjectAutocompleteOutput
import casting.service.project.ProjectMatcher
@Validated
@RestController
@RequestMapping(path = ["/api/casting/project"])
class ProjectMatchController(
private val projectMatcher: ProjectMatcher,
) : ProjectMatchApi{
@PostMapping("/autocomplete")
override fun autocomplete(@RequestBody @Valid projectAutocompleteInput: ProjectAutocompleteInput): List<ProjectAutocompleteOutput> =
projectMatcher.autocomplete(projectAutocompleteInput)
}
casting-client
package io.biznet.casting.client.project
import casting.api.project.ProjectMatchApi
import casting.api.project.resource.input.ProjectAutocompleteInput
import casting.api.project.resource.output.ProjectAutocompleteOutput
@FeignClient(name = "casting", contextId = "castingProjectMatchApiClient")
interface ProjectMatchApiClient : ProjectMatchApi {
@PostMapping("/api/casting/project/autocomplete")
override fun autocomplete(
@RequestBody projectAutocompleteInput: ProjectAutocompleteInput
): List<ProjectAutocompleteOutput>
}
Note the contextId
to avoid any clash with other services’ endpoints. casting-client
also defines a Spring configuration to be imported by other services:
package casting.client.config
@Configuration
@EnableFeignClients("casting.client")
open class CastingFeignClientsConfig
Another service that needs calling casting
A service needing to communicate with casting-service includes casting-client and casting-api as a dependency. Also, it needs to import the configuration:
@Import([
CastingFeignClientsConfig
])
@SpringBootApplication
class SomeOtherApplication {}
The consuming service autowires the Feign client interface provided by casting-client.
The request is serialized into the ProjectAutocompleteInput
DTO defined in casting-api. The response is deserialized into the ProjectAutocompleteOutput
DTO defined in casting-api and returned to the consuming service.
At Whoz, the HTTP communication between Spring microservices is streamlined through a well-structured approach using service, api, and client modules. This structure promotes clean separation of concerns, ensures compatibility, and simplifies the process of inter-service communication.
How does our approach to structuring HTTP communication in Spring microservices compare to your experiences? Are there alternative patterns or tools you’ve found effective for microservice HTTP interaction?