[Learn Microservices With Me] Building A Student API with Spring Cloud

Photo by Growtika on Unsplash

[Learn Microservices With Me] Building A Student API with Spring Cloud

Hi everyone. Today I wanted to share a simple microservices application I created using Spring Cloud. I created this application to help myself learn more about microservices and decided to create this guide so you can learn with me. As we go over the code, I’ll explain all the parts step-by-step so your first microservices project can go smoothly! For you reference, you can have a look at the final project on GitHub.

Prerequisites

To be able to follow this guide, you should have a solid understanding of the fundamentals needed to build a simple Spring Boot application. This includes:

  • Dependency Injection,

  • Spring MVC,

  • Hibernate, and

  • Spring Data JPA

This is important because I'll be skipping over parts of our application involving these things for the sake of brevity.

A Refresher on Microservices

To start, in case you don’t know, let’s first talk about the theory behind microservices. If you’re already aware, feel free to skip ahead to the project architecture.

First, let’s define what microservices are. The term “microservices” refers to an architecture where the application is split into a set of smaller service applications that communicate with each other. In the case of our application, the services will be Spring Boot applications communicating over HTTP. This set of “services” ("microservices") comprises the whole application by collectively providing the different services/features of it. Immediately, the first two problems that arise from this are: "How do we coordinate these services?" and "How can we expect the user/consumer to interact with all these services independently?" Well, I’m glad you asked! First, let’s talk about how we coordinate the services in a microservices application:

Service Registration and Service Discovery

In a microservices application, we typically have a service dedicated to the problem of registering our services (service registration) and helping them find each other (service discovery) so we can effectively coordinate them. In the context of our application, we will be using a service called Consul By HashiCorp as an out-of-the-box solution to start up a server for service registration and service discovery. When our other services start up, they will register themselves with Consul who will be responsible for keeping track of them. When one service wants to communicate with another service, Consul can help the service figure out the other’s hostname. The implication of this in our application, as you’ll see later, is that when we send an HTTP request from one service to another we can use a service name instead of the host in the URL. When we send this request, Consul will help our service resolve the actual hostname of the service we’re trying to talk to! This is great for having flexibility for where our services are deployed especially if we want to implement load balancing.

API Gateways

As for our second problem: "How can we expect the user/consumer to interact with all these services independently?", the quick answer is: we don't! Using an "API gateway", we can have the user use our application as though it were one unified entity. In the context of microservices, an API gateway is a service used as an interface for a user/consumer of our application to cleanly interact with our services. As you’ll see in our application, the API gateway will act as a facade for our microservices allowing the client to communicate with us as though we built our application as a traditional monolith (one single large application). In the implementation, the API Gateway will effectively forward HTTP requests to the appropriate service making it appear as though the HTTP request was handled by a single server (you’ll see more clearly what I mean later).

Pros And Cons of Microservices

Now, you may be asking, why bother with something complicated like this? To answer this, let’s analyze the pros and cons of this approach:

Pros:

  • Flexibility and ease of development: this approach can make development easier in some ways. Here, we can split a large application into smaller, easier-to-understand services. Additionally, each of these services can be developed independently of each other, meaning we can have some flexibility on how each of them is developed, even allowing them to use different programming languages!

  • Scalability: microservices can help with scalability in that service instances can be scaled independently of each other instead of having to scale the entire application (potentially saving resources). This can be helpful if one service requires additional load over others.

  • Fault tolerance: microservices can help your application be more resilient to errors. If we are experiencing problems with one service, we could still have the other services up and running.

Cons:

  • Communication overhead: as you can imagine, this architecture can add extra communication overhead because of the communication between services.

  • Complexity: a distributed architecture clearly adds much more complexity to your application. This can make it more difficult for debugging, integration testing, and sometimes coding.

While it has its use cases for large-scale applications, as you can see, microservices aren't some magical be-all-end-all solution for everything.

Application Architecture

Before we begin implementing our application, let’s first discuss its architecture. To keep our application simple, we’ll create only three services: the API gateway, Student Service, and Course Service. Student Service will be for creating and retrieving students and the Course Service will do the same for courses. Below is a simple diagram of how the whole application will work:

As we talked about previously, the API Gateway will be the interface a client will use to communicate with our application. Every request to our application will first go to the gateway which will then forward them to the correct microservice. Moreover, you can see that each of our services registers themselves to Consul. This will happen automatically for us as soon as each Spring Boot application starts up, allowing our Student Service to leverage service discovery from Consul to communicate with the Course Service (as you’ll see later). Finally, the Course Service and the Student Service each have an independent database they will store data in. For simplicity, I had them connect to the same local MySQL database but using different schemas as though it were a distributed database.

Implementation

Now, let’s dive straight into the implementation. Let’s go through the process step-by-step so you can easily replicate this.

1. Starting Up a Local Consul Instance

First, download Consul here. Using your Consul executable run the following command:

consul agent -dev

Now, you should be able to see Consul running on localhost:8500:

2. Creating a Multi-module Maven Project

Now, let's start writing the code for our project. For my implementation, I chose to create a multi-module Maven project with a root pom.xml and submodules (in separate folders) for each microservice. The advantage of this approach is that we can aggregate common dependencies into the root pom and compile all the modules together. The final file structure of the project would look like this:

Here, we have a pom.xml file in the root folder of the project and one in each subfolder corresponding to a module. The following is the definition of the root pom.xml file:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>io.john.amiscaray</groupId>
    <artifactId>MicroservicesClassroomExample</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>pom</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <java.version>21</java.version>
        <spring-boot.version>3.2.2</spring-boot.version>
        <spring-cloud.version>2023.0.0</spring-cloud.version>
    </properties>

    <modules>
        <module>APIGateway</module>
        <module>StudentService</module>
        <module>CourseService</module>
    </modules>

    <dependencies>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-consul-discovery</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-webflux</artifactId>
        </dependency>
    </dependencies>

    <dependencyManagement>
        <dependencies>
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-dependencies</artifactId>
                <version>${spring-boot.version}</version>
                <type>pom</type>
                <scope>import</scope>
            </dependency>
            <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>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

First, you’ll notice that we declare the names of our submodules in the modules declaration. To be able to specify these submodules, we need to set the packaging property in the pom.xml to “pom”. Next, within the dependencies declaration are common dependencies we’ll need for each microservice. Finally, within the dependencyManagement section we have dependencies that allow Maven to resolve the versions of our other dependencies based on our Spring Boot and Spring Cloud versions.

Note that we must add the spring-boot-starter-actuator dependency for health-checking functionality for our microservices that Consul makes use of.

3. Creating Our API Gateway

With that, we can start implementing our API Gateway. Begin by creating a new module within the multi-module Maven project. In IntelliJ, you can select New > Module… to add the new module setting the build tool to Maven and the parent module as the project specified in the root pom:

Note that I personally chose to not build these modules using the Spring Initializr since I found this easier. It seems that the Spring Initializer automatically set a parent module for these modules to be a Spring Boot Starter module.

In the module's pom.xml file, add the following dependency:

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

Then, within the application.properties file add the following properties:

spring.application.name=api-gateway
server.port=8080
spring.cloud.consul.host=localhost
spring.cloud.consul.port=8500
spring.cloud.consul.discovery.enabled=true
spring.cloud.consul.discovery.register=true

This will set the name of the service for when it registers (the spring.application.name property), the port it will run on, and allow the service to automatically register itself to Consul. Later, we will need to set additional properties for the HTTP request forwarding to our other services.

Now, all we need to do is add the following class:

@SpringBootApplication
@EnableDiscoveryClient
public class APIGatewayApplication {

    public static void main(String[] args) {
        SpringApplication.run(APIGatewayApplication.class, args);
    }

}

This is almost identical to the standard entry point for a Spring Boot application except with a @EnableDiscoveryClient annotation to signify that this will be registered to Consul.

4. Creating Our Student Service

Creating our Student Service should follow a similar process to our API Gateway and what you’re already familiar with in Spring Boot. First, create a new module for our Student Service as shown above. Then add the following dependencies for our database connectivity:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <version>8.3.0</version>
</dependency>

Then, we’ll have a similar entry point class as what you’ve seen with the API Gateway:

@SpringBootApplication
@EnableDiscoveryClient
public class StudentServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(StudentServiceApplication.class, args);
    }

}

Now, for the application.properties file we’ll have the following:

spring.application.name=student-service
server.port=8081
spring.cloud.consul.host=localhost
spring.cloud.consul.port=8500
spring.cloud.consul.discovery.enabled=true
spring.cloud.consul.discovery.register=true
spring.datasource.url=jdbc:mysql://localhost:3306/student
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=create-drop

For the sake of time, let’s skip over creating our ORM classes, repositories, DTOs, and services and show you this service’s controller (you can always see the final code in the GitHub repository):

@RestController
@RequestMapping("/student")
@AllArgsConstructor
public class StudentController {

    private StudentService studentService;
    private CourseServiceClient courseServiceClient;

    @GetMapping("")
    public ResponseEntity<StudentsView> getAllStudents() {
        var students = studentService.getAllStudents()
                .stream()
                .map(StudentDTO::new)
                .toList();

        return ResponseEntity.ok(new StudentsView(students));
    }

    @GetMapping("{id}")
    public ResponseEntity<StudentDTO> getStudentById(@PathVariable(value = "id") long id) {
        var student = studentService.getStudentById(id);

        return ResponseEntity.ok(student);
    }

    @PostMapping("")
    public ResponseEntity<Void> saveStudent(@RequestBody StudentDTO studentDTO) throws URISyntaxException {
        var studentID = studentService.saveStudent(studentDTO);

        return ResponseEntity.created(new URI("/student/" + studentID)).build();
    }

}

5. Configuring Our API Gateway For Our Student Service

Now, we need to configure our API gateway to forward requests to our Student Service. In the API gateway’s application.properties add the following:

spring.cloud.gateway.routes[0].id=student-service
spring.cloud.gateway.routes[0].uri=lb://student-service
spring.cloud.gateway.routes[0].predicates[0]=Path=/student/**

Here, we’re saying that the first route configured in the spring.cloud.gateway.routes array property is for any request to /student subpaths of our API gateway. These requests will be forwarded to our Student Service and can be load-balanced (the lb protocol in the URI) by Spring Cloud.

With that, our Student Service should now be fully functional and accept requests forwarded from our API gateway!

6. Implementing Our Course Service

Now for our Course Service, like our Student Service, we'll create a new submodule, add the same dependencies, and create a similar application.properties file:

spring.application.name=course-service
server.port=8082
spring.cloud.consul.host=localhost
spring.cloud.consul.port=8500
spring.cloud.consul.discovery.enabled=true
spring.cloud.consul.discovery.register=true
spring.datasource.url=jdbc:mysql://localhost:3306/course
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=password
spring.jpa.hibernate.ddl-auto=create-drop

Now from there, we can implement our REST endpoints:

@RestController
@RequestMapping("/course")
@AllArgsConstructor
public class CourseController {

    private CourseService courseService;

    @GetMapping
    public ResponseEntity<CoursesView> getAllCourses() {
        var courses = courseService.getAllCourses();

        return ResponseEntity.ok(new CoursesView(courses));
    }

    @GetMapping("{id}")
    public ResponseEntity<CourseDTO> getCourseById(@PathVariable("id") Long id) {
        var course = courseService.getCourseById(id);

        return ResponseEntity.ok(course);
    }

    @PostMapping
    public ResponseEntity<Void> createCourse(@RequestBody CourseDTO courseDTO) throws URISyntaxException {
        var savedCourseID = courseService.saveCourse(courseDTO);

        return ResponseEntity.created(new URI("/course/" + savedCourseID)).build();
    }

}

7. Configuring Our API Gateway

Finally, we must update the API gateway's application.properties file with path mapping for our Course Service:

spring.cloud.gateway.routes[1].id=course-service
spring.cloud.gateway.routes[1].uri=lb://course-service
spring.cloud.gateway.routes[1].predicates[0]=Path=/course/**

Microservice Communication

With all that, our application should work fairly well now. The Student Service will manage student-related retrieval and creation endpoints and our Course Service will do the same for courses. However, one fundamental concept this application doesn't implement yet is communication between microservices. As I alluded to earlier in the project architecture, we'll also implement microservice communication from the Student Service to the Course Service.

The Motive for Microservice Communication in Our Project

First, I need to explain to you a motive for our microservices communicating with each other. In our Student Service, we use the following Student entity:

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class Student {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String name;
    private String major;
    private Float gpa;

    public Student(StudentDTO studentDTO) {
        name = studentDTO.getName();
        major = studentDTO.getMajor();
        gpa = studentDTO.getGpa();
    }

}

Within our Course Service, we also replicate data (just the Student ID) from the corresponding Student table to maintain a foreign key relation between our Students and Courses:

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Getter
@Setter
public class Student {

    @Id
    private Long id; // Keeping only the student IDs, the rest of the data will be in the Student Service
    @ManyToMany(mappedBy = "takenBy")
    private List<Course> courses;

    public Student(SaveStudentRequest student) {
        id = student.getId();
        courses = new ArrayList<>();
    }

}

@Entity
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class Course {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;
    private String name;
    @Enumerated(EnumType.ORDINAL)
    private CourseTerm term;
    @ManyToMany
    @JoinTable(
            name = "StudentCourse",
            joinColumns = {
                    @JoinColumn(name = "course_id", referencedColumnName = "id"),
            },
            inverseJoinColumns = {
                    @JoinColumn(name = "student_id", referencedColumnName = "id")
            }
    )
    private List<Student> takenBy;

    public Course(CourseDTO courseDTO) {
        name = courseDTO.getName();
        term = courseDTO.getTerm();
        takenBy = new ArrayList<>();
    }

}

The problem with this is that we need a way to insert data into the Course Service's Student table whenever there are new records in the Student table of the Student Service. While what we're about to do is flawed (both in security and possibly from a correctness standpoint) this will show you how to send a request from the Student Service to the Course Service to insert into its Student table. Like before, let's take this step-by-step:

1. Creating a New Endpoint in Our Course Service

To start, let's add a new endpoint in our Course Service for adding new Student records:

@AllArgsConstructor
@NoArgsConstructor
@Getter
public class SaveStudentRequest {

    private Long id;

}

@RestController
@RequestMapping("student")
@AllArgsConstructor
public class StudentController {

    private StudentService studentService;

    @PostMapping
    public ResponseEntity<Void> addStudent(@RequestBody SaveStudentRequest student) {
        studentService.saveStudent(student);

        return ResponseEntity.noContent().build();
    }

}

2. Creating a WebClient.Builder Bean

From our Student Service, we need to configure a bean for a WebClient.Builder class (provided by Spring Webflux) to send requests to the Course Service:

@SpringBootApplication
@EnableDiscoveryClient
public class StudentServiceApplication {

    public static void main(String[] args) {
        SpringApplication.run(StudentServiceApplication.class, args);
    }

    @Bean
    @LoadBalanced
    public WebClient.Builder webClientBuilder() {
        return WebClient.builder();
    }

}

Here, you'll notice the @LoadBalanced annotation added to the method declaration. This is required for Consul to properly resolve the Course Service's host (as you'll see soon).

3. Creating Our CourseServiceClient class

With that, we can create another class for using this WebClient.Builder to send a request to the new endpoint of our Course Service:

@Service
@AllArgsConstructor
public class CourseServiceClient {

    private WebClient.Builder webClientBuilder;

    public Mono<Void> saveNewStudent(CourseSaveStudentRequest saveStudentRequest) {
        return webClientBuilder.build()
                .post()
                .uri("http://course-service/student") // course-service will resolve to the correct host because of consul's service discovery
                .bodyValue(saveStudentRequest)
                .retrieve()
                .bodyToMono(Void.class);
    }

}

Here, we send a POST request to the new /student endpoint of our Course Service. Because of Consul's service discovery capabilities, the course-service part of the URI will be interpreted as the actual hostname of our Course Service! Also, because the WebClient.Builder is part of Spring Webflux (a module for reactive programming in a web context), we get the result back as a Mono. This acts as a wrapper around an asynchronously retrieved value which I believe should be similar in concept to Java's built-in CompleteableFuture.

4. Modifying Our POST Student Endpoint

Using the client, we can now update our endpoint for saving new students:

@RestController
@RequestMapping("/student")
@AllArgsConstructor
public class StudentController {

    private StudentService studentService;
    private CourseServiceClient courseServiceClient;

    // The other endpoints go here...

    @PostMapping("")
    public ResponseEntity<Void> saveStudent(@RequestBody StudentDTO studentDTO) throws URISyntaxException {
        var studentID = studentService.saveStudent(studentDTO);

        courseServiceClient.saveNewStudent(new CourseSaveStudentRequest(studentID))
                .then()
                .subscribe(); // Do nothing when the request goes through.

        return ResponseEntity.created(new URI("/student/" + studentID)).build();
    }

}

Here, whenever we save a new Student into the Student Service's Student table, we'll also send a request using the CourseServiceClient to update the Course Service's Student table.

Conclusion

With that, we successfully created a sample microservices application using an API gateway, communication between microservices, and a simple distributed database. While this application isn't perfect, this should give you a good taste of how you can create your first microservices project! In the future, you can expect follow-up blog posts on more things we can do with our microservices to improve this project. Happy coding!