Building the Composite Service
Now, it’s time to combine everything by adding the Composite Service. This service is the one that will call our three core services!
The Composite Service is implemented in two parts:
- An Integration Component that sends the necessary HTTP requests to the core services.
- The Composite Service Implementation itself.
We divide the work this way because it simplifies testing. We can test the main service logic separately by replacing the Integration Component with a mock (a dummy version that imitates the real one). This makes automated unit and integration testing much easier.
Before we look at the code for these two parts, we need to understand the API classes that the composite microservice will use. We also need to see how we use runtime properties to store the network addresses (like the host and port) of the core microservices.
The file structure for the API looks like this:
microservices-with-spring-boot/api
└──src/main/java/com/htp/microservices/api/composite
├──models
│ ├──CourseAggregate
│ ├──ChapterSummary
│ ├──QuizSummary
│ └──ServiceAddresses
└──services
└──CourseCompositeService
These are the data structures (Java records) we will use:
public record ServiceAddresses(
String course,
String chapter,
String quiz,
String composite) {
}
public record QuizSummary(
Long quizId,
String question,
String correctAnswer,
List<String> answerOptions) {
}
public record ChapterSummary(
Long chapterId,
String title,
String content,
List<QuizSummary> quizzes) {
}
The Java interface class CourseCompositeService uses the same design pattern as the core services. It looks like this:
public interface CourseCompositeService {
@GetMapping("/{courseId}")
CourseAggregate getCourse(@PathVariable Long courseId);
}
The model class CourseAggregate is a bit more complex than the core data models because it contains fields for lists of chapters and their details:
public record CourseAggregate(
Long courseId,
String title,
String description,
CourseDifficultyLevel difficultyLevel,
ServiceAddresses serviceAddresses,
List<ChapterSummary> chapters
) {
}
Okay, now let’s start the implementation.
Configuration and Properties
To avoid putting the core service addresses directly into the Java source code, the composite microservice uses a property file. This file holds the information needed to find the core services.
The property file, located at \course-composite-service\src\main\resources\application.yml, is shown below:
spring:
application:
name: course-composite-service
server:
port: 7000
error:
include-message: always
microservices:
course-service:
host: localhost
port: 8082
chapter-service:
host: localhost
port: 8081
quiz-service:
host: localhost
port: 8003
logging:
level:
root: INFO
se.magnus: DEBUG
As we noted, this current setup will be replaced later by a service discovery mechanism. But for now, let’s create the configuration class to read these properties.
The configuration class uses Java records and the @ConfigurationProperties annotation to easily read the YAML data:
@ConfigurationProperties(prefix = "microservices")
public record MicroservicesConfig(
ServiceConfig courseService,
ServiceConfig chapterService,
ServiceConfig quizService
) {
public record ServiceConfig(String host, int port) {
}
public String getCourseServiceUrl() {
return "http://" + courseService.host() + ":" + courseService.port() + "/api/v1/courses";
}
public String getChapterServiceUrl() {
return "http://" + chapterService.host() + ":" + chapterService.port() + "/api/v1/chapters";
}
public String getQuizServiceUrl() {
return "http://" + quizService.host() + ":" + quizService.port() + "/api/v1/quizzes";
}
}
The Integration Component
Let’s look at the first part of the composite microservice’s implementation: the Integration Component, named CourseCompositeIntegrationService.java.
It is set up as a Spring Bean using the @Service annotation and implements the API interfaces of the three core services:
@Slf4j
@Service
@RequiredArgsConstructor
public class CourseCompositeIntegrationService implements CourseService, ChapterService, QuizService {
The Integration Component uses a utility class from the Spring Framework called RestTemplate to handle the actual outgoing HTTP requests to the core microservices.
Before we can use RestTemplate in our component, we need to configure it. We do this in the main application class, CourseCompositeServiceApplication.java:
@SpringBootApplication
@ComponentScan("com.htp")
@EnableConfigurationProperties(MicroservicesConfig.class)
public class CourseCompositeServiceApplication {
public static void main(String[] args) {
SpringApplication.run(CourseCompositeServiceApplication.class, args);
}
@Bean
RestTemplate restTemplate() {
return new RestTemplate();
}
}
Now, the Integration Component implements the API methods for the three core services by using the configured RestTemplate to make the calls:
We can now inject (or give) the RestTemplate, along with a JSON mapper (which helps with error messages) and the
configuration values from our property file. Here is how this is done
@Slf4j
@Service
@RequiredArgsConstructor
public class CourseCompositeIntegrationService implements CourseService, ChapterService, QuizService {
private final RestTemplate restTemplate;
private final MicroservicesConfig microservicesConfig;
@Override
public Course getCourseById(Long courseId) {
var url = microservicesConfig.getCourseServiceUrl() + "/" + courseId;
return restTemplate.getForObject(url, Course.class);
}
@Override
public List<Chapter> getChaptersByCourseId(Long courseId) {
var url = microservicesConfig.getChapterServiceUrl() + "?courseId=" + courseId;
return restTemplate.exchange(url, GET, null,
new ParameterizedTypeReference<List<Chapter>>() {
}).getBody();
}
@Override
public List<Quiz> getQuizzesByChapterId(Long chapterId) {
var url = microservicesConfig.getQuizServiceUrl() + "?chapterId=" + chapterId;
return restTemplate.exchange(url, GET, null,
new ParameterizedTypeReference<List<Quiz>>() {
}).getBody();
}
}
Here are some important points about how the methods are implemented:
- For
getCourseById(): We can use the simplegetForObject()method inRestTemplate. We expect a single Course object. We tellgetForObject()to map the JSON response directly to theCourse.classclass. - For methods that return lists (like
getChaptersByCourseId()): We must use the more complex method,exchange(). The reason is how Java handles generics (likeList<Chapter>). At runtime, Java loses the specific type information (it only knows it’s aList, not aList<Chapter>). This is called type erasure.- To solve this, we use the Spring helper class,
ParameterizedTypeReference. This class is designed to keep the type information (likeList<Chapter>) at runtime. - Because we need this helper, we must use the more detailed
exchange()method instead of the simplergetForObject()method onRestTemplate.
- To solve this, we use the Spring helper class,
Composite API Implementation
The final step in building our composite microservice is implementing the API layer, represented by the class CourseCompositeServiceImpl.
Before looking at that class, we need a few helper components that prepare and transform data for the composite response. These utilities keep the main service clean and maintainable.
ServiceAddressResolverUtil
This utility class extracts service-address information from the responses of individual microservices.
Because service addresses may be null or missing, this helper ensures that every extraction is safe and returns a valid string.
public interface ServiceAddressResolverUtil {
static String resolveCourseAddress(Course course) {
if (course == null) {
return "";
}
var address = course.serviceAddress();
return address != null ? address : "";
}
static String resolveChapterAddress(List<Chapter> chapters) {
if (CollectionUtils.isEmpty(chapters)) {
return "";
}
return chapters.stream()
.map(Chapter::serviceAddress)
.filter(StringUtils::hasLength)
.findAny()
.orElse("");
}
static String resolveQuizAddress(List<Quiz> quizzes) {
if (CollectionUtils.isEmpty(quizzes)) {
return "";
}
return quizzes.stream()
.map(Quiz::serviceAddress)
.filter(StringUtils::hasLength)
.findAny()
.orElse("");
}
}
CourseMapper
Purpose: Converts a Course and related service-address information into a CourseAggregate.
This mapper keeps the composite service from being overloaded with transformation logic.
@Component
public class CourseMapper {
public CourseAggregate toCourseAggregate(
Course course,
ServiceAddresses serviceAddresses,
List<ChapterSummary> chapters
) {
return new CourseAggregate(
course.courseId(),
course.title(),
course.description(),
course.difficultyLevel(),
serviceAddresses,
chapters
);
}
}
QuizMapper
Purpose: Transforms a list of Quiz entities into simplified QuizSummary DTOs.
It helps the composite service return only the necessary quiz information instead of full domain objects.
@Component
public class QuizMapper {
public List<QuizSummary> toQuizSummaries(List<Quiz> quizzes) {
return quizzes.stream()
.map(this::toQuizSummary)
.toList();
}
public QuizSummary toQuizSummary(Quiz quiz) {
return new QuizSummary(
quiz.quizId(),
quiz.question(),
quiz.correctAnswer(),
quiz.answerOptions());
}
}
ChapterMapper
Purpose: Builds a list of ChapterSummary objects by combining chapter data with the quizzes that belong to each chapter.
It delegates quiz transformation to QuizMapper to keep responsibility separated.
@Component
@RequiredArgsConstructor
public class ChapterMapper {
private final QuizMapper quizMapper;
public List<ChapterSummary> toChapterSummaries(
List<Chapter> chapters,
Map<Long, List<Quiz>> quizMap
) {
return chapters.stream()
.map(ch -> new ChapterSummary(
ch.chapterId(),
ch.title(),
ch.content(),
quizMapper.toQuizSummaries(quizMap.getOrDefault(ch.chapterId(), List.of()))
)).toList();
}
}
ServiceAddressesMapper
Purpose: Combines all resolved service addresses—course, chapters, quizzes—and the composite service’s own address into a single ServiceAddresses object.
This allows clients to trace where each piece of data originated.
@Component
@RequiredArgsConstructor
public class ServiceAddressesMapper {
private final ServiceUtil serviceUtil;
public ServiceAddresses toAddresses(Course course, List<Chapter> chapters, List<Quiz> quizzes) {
var courseAddress = ServiceAddressResolverUtil.resolveCourseAddress(course);
var chapterAddress = ServiceAddressResolverUtil.resolveChapterAddress(chapters);
var quizAddress = ServiceAddressResolverUtil.resolveQuizAddress(quizzes);
return new ServiceAddresses(
courseAddress,
chapterAddress,
quizAddress,
serviceUtil.getServiceAddress()
);
}
}
CourseCompositeServiceImpl
With all helper classes prepared, we can now implement the main composite API.
This class orchestrates calls to the course, chapter, and quiz microservices, then aggregates the results into a single response.
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/course-composite")
public class CourseCompositeServiceImpl implements CourseCompositeService {
private final CourseMapper courseMapper;
private final ChapterMapper chapterMapper;
private final ServiceAddressesMapper serviceAddressesMapper;
private final CourseCompositeIntegrationService integrationService;
@Override
public CourseAggregate getCourse(Long courseId) {
log.info("Fetching composite course structure for courseId={}", courseId);
var course = integrationService.getCourseById(courseId);
if (course == null) {
log.warn("Course not found: {}", courseId);
throw new NotFoundException("Course not found with id: " + courseId);
}
var chapters = integrationService.getChaptersByCourseId(course.courseId());
log.debug("Fetched {} chapters for course {}", chapters.size(), courseId);
Map<Long, List<Quiz>> quizMap = chapters.stream()
.collect(Collectors.toMap(
Chapter::chapterId,
chapter -> {
var chapterId = chapter.chapterId();
var quizzes = integrationService.getQuizzesByChapterId(chapterId);
log.debug("Found {} quizzes for chapter {}", quizzes.size(), chapterId);
return quizzes;
}
));
var allQuizzes = quizMap.values().stream().flatMap(List::stream).toList();
var addresses = serviceAddressesMapper.toAddresses(course, chapters, allQuizzes);
var chapterSummaries = chapterMapper.toChapterSummaries(chapters, quizMap);
return courseMapper.toCourseAggregate(course, addresses, chapterSummaries);
}
}
The source code for this article is available over on GitHub.