Lab - Refactoring to Microservices

Lab Objective

In this lab, we’ll convert our Book Catalog application from a monolithic architecture to a microservices architecture using Spring Cloud components. We’ll break down the application into separate services, implement service discovery with Eureka, and enable inter-service communication using Feign.

Prerequisites

  • Completed Module 6 (Spring Kafka implementation)
  • JDK 17 or later
  • Maven or Gradle
  • IDE (IntelliJ IDEA or Eclipse)

Step 1: Setting Up the Project Structure

We’ll create the following microservices:

  1. Discovery Service: Eureka Server for service discovery
  2. Config Service: Spring Cloud Config Server for centralized configuration
  3. Book Service: Core book catalog functionality
  4. User Service: User management and authentication
  5. Analytics Service: Processing analytics events

Let’s start by creating a parent Maven project to manage all microservices:

mkdir book-catalog-microservices
cd book-catalog-microservices

Create a parent 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>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>book-catalog-microservices</artifactId>
    <version>1.0.0</version>
    <packaging>pom</packaging>

    <properties>
        <java.version>17</java.version>
        <spring-cloud.version>2022.0.3</spring-cloud.version>
    </properties>

    <modules>
        <module>discovery-service</module>
        <module>config-service</module>
        <module>book-service</module>
        <module>user-service</module>
        <module>analytics-service</module>
    </modules>

    <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>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Step 2: Creating the Discovery Service (Eureka Server)

Create a new directory for the discovery service:

mkdir -p discovery-service/src/main/java/com/example/discovery
mkdir -p discovery-service/src/main/resources

Create the pom.xml for the discovery service:

<?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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>book-catalog-microservices</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>discovery-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Create the main application class:

// discovery-service/src/main/java/com/example/discovery/DiscoveryServiceApplication.java
package com.example.discovery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer;

@SpringBootApplication
@EnableEurekaServer
public class DiscoveryServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(DiscoveryServiceApplication.class, args);
    }
}

Create the application properties file:

# discovery-service/src/main/resources/application.properties
spring.application.name=discovery-service
server.port=8761

# Don't register the server with itself
eureka.client.register-with-eureka=false
eureka.client.fetch-registry=false

# Logging
logging.level.com.netflix.eureka=INFO
logging.level.com.netflix.discovery=INFO

# Actuator endpoints
management.endpoints.web.exposure.include=*

Step 3: Creating the Config Service

Create a new directory for the config service:

mkdir -p config-service/src/main/java/com/example/config
mkdir -p config-service/src/main/resources

Create the pom.xml for the config service:

<?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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>book-catalog-microservices</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>config-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-config-server</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

Create the main application class:

// config-service/src/main/java/com/example/config/ConfigServiceApplication.java
package com.example.config;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.config.server.EnableConfigServer;

@SpringBootApplication
@EnableConfigServer
@EnableDiscoveryClient
public class ConfigServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(ConfigServiceApplication.class, args);
    }
}

Create the application properties file:

# config-service/src/main/resources/application.properties
spring.application.name=config-service
server.port=8888

# Configure the Git repository
spring.cloud.config.server.git.uri=file:////${user.home}/config-repo

# Eureka client configuration
eureka.client.service-url.defaultZone=http://localhost:8761/eureka/

# Actuator endpoints
management.endpoints.web.exposure.include=*

Now, let’s create a local Git repository for our configuration files:

mkdir -p ~/config-repo
cd ~/config-repo
git init

Create configuration files for each service:

# ~/config-repo/book-service.properties
server.port=8081
spring.datasource.url=jdbc:postgresql://localhost:5432/bookdb
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
# ~/config-repo/user-service.properties
server.port=8082
spring.datasource.url=jdbc:postgresql://localhost:5432/userdb
spring.datasource.username=postgres
spring.datasource.password=postgres
spring.jpa.hibernate.ddl-auto=update
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.producer.key-serializer=org.apache.kafka.common.serialization.StringSerializer
spring.kafka.producer.value-serializer=org.springframework.kafka.support.serializer.JsonSerializer
# ~/config-repo/analytics-service.properties
server.port=8083
spring.kafka.bootstrap-servers=localhost:9092
spring.kafka.consumer.group-id=analytics-group
spring.kafka.consumer.auto-offset-reset=earliest
spring.kafka.consumer.key-deserializer=org.apache.kafka.common.serialization.StringDeserializer
spring.kafka.consumer.value-deserializer=org.springframework.kafka.support.serializer.JsonDeserializer
spring.kafka.consumer.properties.spring.json.trusted.packages=com.example.*

Commit these files to the Git repository:

cd ~/config-repo
git add .
git commit -m "Initial configuration files"

Step 4: Creating the Book Service

Create a new directory for the book service:

mkdir -p book-service/src/main/java/com/example/book
mkdir -p book-service/src/main/resources

Create the pom.xml for the book service:

<?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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>book-catalog-microservices</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>book-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-openfeign</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <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>

Create the bootstrap.properties file:

# book-service/src/main/resources/bootstrap.properties
spring.application.name=book-service
spring.cloud.config.uri=http://localhost:8888
spring.config.import=optional:configserver:http://localhost:8888

Create the main application class:

// book-service/src/main/java/com/example/book/BookServiceApplication.java
package com.example.book;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.cloud.openfeign.EnableFeignClients;

@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class BookServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(BookServiceApplication.class, args);
    }
}

Let’s create the necessary model, repository, and controller classes:

// book-service/src/main/java/com/example/book/model/Book.java
package com.example.book.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String title;
    private String author;
    private String isbn;
    private Double price;
    private Integer userId;  // Reference to the user who owns this book
}
// book-service/src/main/java/com/example/book/repository/BookRepository.java
package com.example.book.repository;

import com.example.book.model.Book;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface BookRepository extends JpaRepository<Book, Long> {
    List<Book> findByUserId(Integer userId);
}

Create the DTO for book events:

// book-service/src/main/java/com/example/book/dto/BookEvent.java
package com.example.book.dto;

import com.example.book.model.Book;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class BookEvent {
    public enum EventType {
        CREATED, UPDATED, DELETED
    }

    private EventType eventType;
    private Book book;
}

Create the Feign client to communicate with the User Service:

// book-service/src/main/java/com/example/book/client/UserClient.java
package com.example.book.client;

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(name = "user-service")
public interface UserClient {
    @GetMapping("/api/users/{id}")
    UserDto getUserById(@PathVariable("id") Integer id);
}
// book-service/src/main/java/com/example/book/client/UserDto.java
package com.example.book.client;

import lombok.Data;

@Data
public class UserDto {
    private Integer id;
    private String username;
    private String email;
}

Create a Kafka producer service:

// book-service/src/main/java/com/example/book/kafka/BookEventProducer.java
package com.example.book.kafka;

import com.example.book.dto.BookEvent;
import com.example.book.model.Book;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Service
public class BookEventProducer {
    private static final String TOPIC = "book-events";

    @Autowired
    private KafkaTemplate<String, BookEvent> kafkaTemplate;

    public void sendBookCreatedEvent(Book book) {
        BookEvent event = new BookEvent(BookEvent.EventType.CREATED, book);
        kafkaTemplate.send(TOPIC, event);
    }

    public void sendBookUpdatedEvent(Book book) {
        BookEvent event = new BookEvent(BookEvent.EventType.UPDATED, book);
        kafkaTemplate.send(TOPIC, event);
    }

    public void sendBookDeletedEvent(Book book) {
        BookEvent event = new BookEvent(BookEvent.EventType.DELETED, book);
        kafkaTemplate.send(TOPIC, event);
    }
}

Create the service layer:

// book-service/src/main/java/com/example/book/service/BookService.java
package com.example.book.service;

import com.example.book.client.UserClient;
import com.example.book.dto.BookEvent;
import com.example.book.kafka.BookEventProducer;
import com.example.book.model.Book;
import com.example.book.repository.BookRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
public class BookService {

    @Autowired
    private BookRepository bookRepository;

    @Autowired
    private BookEventProducer eventProducer;

    @Autowired
    private UserClient userClient;

    public List<Book> getAllBooks() {
        return bookRepository.findAll();
    }

    public Optional<Book> getBookById(Long id) {
        return bookRepository.findById(id);
    }

    public List<Book> getBooksByUserId(Integer userId) {
        // Verify that the user exists
        try {
            userClient.getUserById(userId);
            return bookRepository.findByUserId(userId);
        } catch (Exception e) {
            throw new RuntimeException("User not found or user service unavailable");
        }
    }

    public Book createBook(Book book) {
        // Verify that the user exists if userId is provided
        if (book.getUserId() != null) {
            try {
                userClient.getUserById(book.getUserId());
            } catch (Exception e) {
                throw new RuntimeException("User not found or user service unavailable");
            }
        }

        Book savedBook = bookRepository.save(book);
        eventProducer.sendBookCreatedEvent(savedBook);
        return savedBook;
    }

    public Book updateBook(Long id, Book bookDetails) {
        return bookRepository.findById(id)
                .map(existingBook -> {
                    existingBook.setTitle(bookDetails.getTitle());
                    existingBook.setAuthor(bookDetails.getAuthor());
                    existingBook.setIsbn(bookDetails.getIsbn());
                    existingBook.setPrice(bookDetails.getPrice());

                    Book updatedBook = bookRepository.save(existingBook);
                    eventProducer.sendBookUpdatedEvent(updatedBook);
                    return updatedBook;
                })
                .orElseThrow(() -> new RuntimeException("Book not found with id " + id));
    }

    public void deleteBook(Long id) {
        Book book = bookRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("Book not found with id " + id));

        bookRepository.delete(book);
        eventProducer.sendBookDeletedEvent(book);
    }
}

Finally, create the controller:

// book-service/src/main/java/com/example/book/controller/BookController.java
package com.example.book.controller;

import com.example.book.model.Book;
import com.example.book.service.BookService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/books")
public class BookController {

    @Autowired
    private BookService bookService;

    @GetMapping
    public ResponseEntity<List<Book>> getAllBooks() {
        return ResponseEntity.ok(bookService.getAllBooks());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Book> getBookById(@PathVariable Long id) {
        return bookService.getBookById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/user/{userId}")
    public ResponseEntity<List<Book>> getBooksByUserId(@PathVariable Integer userId) {
        try {
            List<Book> books = bookService.getBooksByUserId(userId);
            return ResponseEntity.ok(books);
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
    }

    @PostMapping
    public ResponseEntity<Book> createBook(@RequestBody Book book) {
        try {
            Book createdBook = bookService.createBook(book);
            return ResponseEntity.status(HttpStatus.CREATED).body(createdBook);
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
        }
    }

    @PutMapping("/{id}")
    public ResponseEntity<Book> updateBook(@PathVariable Long id, @RequestBody Book bookDetails) {
        try {
            Book updatedBook = bookService.updateBook(id, bookDetails);
            return ResponseEntity.ok(updatedBook);
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteBook(@PathVariable Long id) {
        try {
            bookService.deleteBook(id);
            return ResponseEntity.noContent().build();
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
    }
}

Step 5: Creating the User Service

Create a new directory for the user service:

mkdir -p user-service/src/main/java/com/example/user
mkdir -p user-service/src/main/resources

Create the pom.xml for the user service:

<?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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>book-catalog-microservices</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>user-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.postgresql</groupId>
            <artifactId>postgresql</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <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>

Create the bootstrap.properties file:

# user-service/src/main/resources/bootstrap.properties
spring.application.name=user-service
spring.cloud.config.uri=http://localhost:8888
spring.config.import=optional:configserver:http://localhost:8888

Create the main application class:

// user-service/src/main/java/com/example/user/UserServiceApplication.java
package com.example.user;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class UserServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(UserServiceApplication.class, args);
    }
}

Let’s create the necessary model, repository, and controller classes:

// user-service/src/main/java/com/example/user/model/User.java
package com.example.user.model;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Table(name = "users") // "user" is a reserved keyword in PostgreSQL
@Data
@NoArgsConstructor
@AllArgsConstructor
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String username;
    private String email;
    private String password; // In a real app, this would be encrypted
}
// user-service/src/main/java/com/example/user/dto/UserDto.java
package com.example.user.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserDto {
    private Integer id;
    private String username;
    private String email;
    // Exclude password for security
}
// user-service/src/main/java/com/example/user/dto/UserEvent.java
package com.example.user.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor
@AllArgsConstructor
public class UserEvent {
    public enum EventType {
        REGISTERED, UPDATED, DELETED
    }

    private EventType eventType;
    private UserDto user;
}
// user-service/src/main/java/com/example/user/repository/UserRepository.java
package com.example.user.repository;

import com.example.user.model.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Integer> {
    Optional<User> findByUsername(String username);
    Optional<User> findByEmail(String email);
    boolean existsByUsername(String username);
    boolean existsByEmail(String email);
}

Create a Kafka producer service:

// user-service/src/main/java/com/example/user/kafka/UserEventProducer.java
package com.example.user.kafka;

import com.example.user.dto.UserDto;
import com.example.user.dto.UserEvent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;

@Service
public class UserEventProducer {
    private static final String TOPIC = "user-events";

    @Autowired
    private KafkaTemplate<String, UserEvent> kafkaTemplate;

    public void sendUserRegisteredEvent(UserDto user) {
        UserEvent event = new UserEvent(UserEvent.EventType.REGISTERED, user);
        kafkaTemplate.send(TOPIC, event);
    }

    public void sendUserUpdatedEvent(UserDto user) {
        UserEvent event = new UserEvent(UserEvent.EventType.UPDATED, user);
        kafkaTemplate.send(TOPIC, event);
    }

    public void sendUserDeletedEvent(UserDto user) {
        UserEvent event = new UserEvent(UserEvent.EventType.DELETED, user);
        kafkaTemplate.send(TOPIC, event);
    }
}

Create the service layer:

// user-service/src/main/java/com/example/user/service/UserService.java
package com.example.user.service;

import com.example.user.dto.UserDto;
import com.example.user.dto.UserEvent;
import com.example.user.kafka.UserEventProducer;
import com.example.user.model.User;
import com.example.user.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

@Service
public class UserService {

    @Autowired
    private UserRepository userRepository;

    @Autowired
    private UserEventProducer eventProducer;

    public List<UserDto> getAllUsers() {
        return userRepository.findAll().stream()
                .map(this::convertToDto)
                .collect(Collectors.toList());
    }

    public Optional<UserDto> getUserById(Integer id) {
        return userRepository.findById(id)
                .map(this::convertToDto);
    }

    public Optional<UserDto> getUserByUsername(String username) {
        return userRepository.findByUsername(username)
                .map(this::convertToDto);
    }

    public UserDto createUser(User user) {
        if (userRepository.existsByUsername(user.getUsername())) {
            throw new RuntimeException("Username already exists");
        }

        if (userRepository.existsByEmail(user.getEmail())) {
            throw new RuntimeException("Email already exists");
        }

        User savedUser = userRepository.save(user);
        UserDto userDto = convertToDto(savedUser);
        eventProducer.sendUserRegisteredEvent(userDto);
        return userDto;
    }

    public UserDto updateUser(Integer id, User userDetails) {
        return userRepository.findById(id)
                .map(existingUser -> {
                    // Check if username is being changed and if it's already taken
                    if (!existingUser.getUsername().equals(userDetails.getUsername()) &&
                            userRepository.existsByUsername(userDetails.getUsername())) {
                        throw new RuntimeException("Username already exists");
                    }

                    // Check if email is being changed and if it's already taken
                    if (!existingUser.getEmail().equals(userDetails.getEmail()) &&
                            userRepository.existsByEmail(userDetails.getEmail())) {
                        throw new RuntimeException("Email already exists");
                    }

                    existingUser.setUsername(userDetails.getUsername());
                    existingUser.setEmail(userDetails.getEmail());

                    if (userDetails.getPassword() != null && !userDetails.getPassword().isEmpty()) {
                        existingUser.setPassword(userDetails.getPassword());
                    }

                    User updatedUser = userRepository.save(existingUser);
                    UserDto userDto = convertToDto(updatedUser);
                    eventProducer.sendUserUpdatedEvent(userDto);
                    return userDto;
                })
                .orElseThrow(() -> new RuntimeException("User not found with id " + id));
    }

    public void deleteUser(Integer id) {
        User user = userRepository.findById(id)
                .orElseThrow(() -> new RuntimeException("User not found with id " + id));

        userRepository.delete(user);
        UserDto userDto = convertToDto(user);
        eventProducer.sendUserDeletedEvent(userDto);
    }

    private UserDto convertToDto(User user) {
        return new UserDto(user.getId(), user.getUsername(), user.getEmail());
    }
}

Finally, create the controller:

// user-service/src/main/java/com/example/user/controller/UserController.java
package com.example.user.controller;

import com.example.user.dto.UserDto;
import com.example.user.model.User;
import com.example.user.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/users")
public class UserController {

    @Autowired
    private UserService userService;

    @GetMapping
    public ResponseEntity<List<UserDto>> getAllUsers() {
        return ResponseEntity.ok(userService.getAllUsers());
    }

    @GetMapping("/{id}")
    public ResponseEntity<UserDto> getUserById(@PathVariable Integer id) {
        return userService.getUserById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/username/{username}")
    public ResponseEntity<UserDto> getUserByUsername(@PathVariable String username) {
        return userService.getUserByUsername(username)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping
    public ResponseEntity<?> createUser(@RequestBody User user) {
        try {
            UserDto createdUser = userService.createUser(user);
            return ResponseEntity.status(HttpStatus.CREATED).body(createdUser);
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }

    @PutMapping("/{id}")
    public ResponseEntity<?> updateUser(@PathVariable Integer id, @RequestBody User userDetails) {
        try {
            UserDto updatedUser = userService.updateUser(id, userDetails);
            return ResponseEntity.ok(updatedUser);
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(e.getMessage());
        }
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Integer id) {
        try {
            userService.deleteUser(id);
            return ResponseEntity.noContent().build();
        } catch (RuntimeException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
        }
    }
}

Step 6: Creating the Analytics Service

Create a new directory for the analytics service:

mkdir -p analytics-service/src/main/java/com/example/analytics
mkdir -p analytics-service/src/main/resources

Create the pom.xml for the analytics service:

<?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>

    <parent>
        <groupId>com.example</groupId>
        <artifactId>book-catalog-microservices</artifactId>
        <version>1.0.0</version>
    </parent>

    <artifactId>analytics-service</artifactId>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.kafka</groupId>
            <artifactId>spring-kafka</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.cloud</groupId>
            <artifactId>spring-cloud-starter-config</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-actuator</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>
    </dependencies>

    <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>

Create the bootstrap.properties file:

# analytics-service/src/main/resources/bootstrap.properties
spring.application.name=analytics-service
spring.cloud.config.uri=http://localhost:8888
spring.config.import=optional:configserver:http://localhost:8888

Create the main application class:

// analytics-service/src/main/java/com/example/analytics/AnalyticsServiceApplication.java
package com.example.analytics;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;

@SpringBootApplication
@EnableDiscoveryClient
public class AnalyticsServiceApplication {
    public static void main(String[] args) {
        SpringApplication.run(AnalyticsServiceApplication.class, args);
    }
}

Let’s create the DTOs to deserialize incoming events:

// analytics-service/src/main/java/com/example/analytics/dto/Book.java
package com.example.analytics.dto;

import lombok.Data;

@Data
public class Book {
    private Long id;
    private String title;
    private String author;
    private String isbn;
    private Double price;
    private Integer userId;
}
// analytics-service/src/main/java/com/example/analytics/dto/BookEvent.java
package com.example.analytics.dto;

import lombok.Data;

@Data
public class BookEvent {
    public enum EventType {
        CREATED, UPDATED, DELETED
    }

    private EventType eventType;
    private Book book;
}
// analytics-service/src/main/java/com/example/analytics/dto/UserDto.java
package com.example.analytics.dto;

import lombok.Data;

@Data
public class UserDto {
    private Integer id;
    private String username;
    private String email;
}
// analytics-service/src/main/java/com/example/analytics/dto/UserEvent.java
package com.example.analytics.dto;

import lombok.Data;

@Data
public class UserEvent {
    public enum EventType {
        REGISTERED, UPDATED, DELETED
    }

    private EventType eventType;
    private UserDto user;
}

Create the Kafka consumer to process book and user events:

// analytics-service/src/main/java/com/example/analytics/kafka/BookEventConsumer.java
package com.example.analytics.kafka;

import com.example.analytics.dto.BookEvent;
import com.example.analytics.service.AnalyticsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

@Service
public class BookEventConsumer {
    private static final Logger logger = LoggerFactory.getLogger(BookEventConsumer.class);

    @Autowired
    private AnalyticsService analyticsService;

    @KafkaListener(topics = "book-events", groupId = "analytics-group")
    public void consume(BookEvent event) {
        logger.info("Received book event: {}", event);

        switch (event.getEventType()) {
            case CREATED:
                analyticsService.recordBookCreated(event.getBook());
                break;
            case UPDATED:
                analyticsService.recordBookUpdated(event.getBook());
                break;
            case DELETED:
                analyticsService.recordBookDeleted(event.getBook());
                break;
        }
    }
}
// analytics-service/src/main/java/com/example/analytics/kafka/UserEventConsumer.java
package com.example.analytics.kafka;

import com.example.analytics.dto.UserEvent;
import com.example.analytics.service.AnalyticsService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Service;

@Service
public class UserEventConsumer {
    private static final Logger logger = LoggerFactory.getLogger(UserEventConsumer.class);

    @Autowired
    private AnalyticsService analyticsService;

    @KafkaListener(topics = "user-events", groupId = "analytics-group")
    public void consume(UserEvent event) {
        logger.info("Received user event: {}", event);

        switch (event.getEventType()) {
            case REGISTERED:
                analyticsService.recordUserRegistered(event.getUser());
                break;
            case UPDATED:
                analyticsService.recordUserUpdated(event.getUser());
                break;
            case DELETED:
                analyticsService.recordUserDeleted(event.getUser());
                break;
        }
    }
}

Create the analytics service:

// analytics-service/src/main/java/com/example/analytics/service/AnalyticsService.java
package com.example.analytics.service;

import com.example.analytics.dto.Book;
import com.example.analytics.dto.UserDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Service
public class AnalyticsService {
    private static final Logger logger = LoggerFactory.getLogger(AnalyticsService.class);

    // Counters for analytics
    private final AtomicInteger totalBooks = new AtomicInteger(0);
    private final AtomicInteger totalUsers = new AtomicInteger(0);
    private final Map<String, AtomicInteger> booksByAuthor = new ConcurrentHashMap<>();
    private final Map<String, AtomicInteger> actionCounts = new ConcurrentHashMap<>();

    public void recordBookCreated(Book book) {
        logger.info("Recording book created: {}", book.getTitle());
        totalBooks.incrementAndGet();
        booksByAuthor.computeIfAbsent(book.getAuthor(), k -> new AtomicInteger(0)).incrementAndGet();
        incrementActionCount("BOOK_CREATED");
    }

    public void recordBookUpdated(Book book) {
        logger.info("Recording book updated: {}", book.getTitle());
        incrementActionCount("BOOK_UPDATED");
    }

    public void recordBookDeleted(Book book) {
        logger.info("Recording book deleted: {}", book.getTitle());
        totalBooks.decrementAndGet();
        booksByAuthor.computeIfPresent(book.getAuthor(), (k, v) -> {
            int newCount = v.decrementAndGet();
            return newCount <= 0 ? null : v;
        });
        incrementActionCount("BOOK_DELETED");
    }

    public void recordUserRegistered(UserDto user) {
        logger.info("Recording user registered: {}", user.getUsername());
        totalUsers.incrementAndGet();
        incrementActionCount("USER_REGISTERED");
    }

    public void recordUserUpdated(UserDto user) {
        logger.info("Recording user updated: {}", user.getUsername());
        incrementActionCount("USER_UPDATED");
    }

    public void recordUserDeleted(UserDto user) {
        logger.info("Recording user deleted: {}", user.getUsername());
        totalUsers.decrementAndGet();
        incrementActionCount("USER_DELETED");
    }

    private void incrementActionCount(String action) {
        actionCounts.computeIfAbsent(action, k -> new AtomicInteger(0)).incrementAndGet();
    }

    // Methods to retrieve analytics data
    public int getTotalBooks() {
        return totalBooks.get();
    }

    public int getTotalUsers() {
        return totalUsers.get();
    }

    public Map<String, AtomicInteger> getBooksByAuthor() {
        return booksByAuthor;
    }

    public Map<String, AtomicInteger> getActionCounts() {
        return actionCounts;
    }
}

Finally, create the controller to expose analytics data:

// analytics-service/src/main/java/com/example/analytics/controller/AnalyticsController.java
package com.example.analytics.controller;

import com.example.analytics.service.AnalyticsService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

@RestController
@RequestMapping("/api/analytics")
public class AnalyticsController {

    @Autowired
    private AnalyticsService analyticsService;

    @GetMapping("/summary")
    public Map<String, Object> getAnalyticsSummary() {
        Map<String, Object> summary = new HashMap<>();
        summary.put("totalBooks", analyticsService.getTotalBooks());
        summary.put("totalUsers", analyticsService.getTotalUsers());

        Map<String, Integer> booksByAuthor = new HashMap<>();
        analyticsService.getBooksByAuthor().forEach((author, count) ->
            booksByAuthor.put(author, count.get()));
        summary.put("booksByAuthor", booksByAuthor);

        Map<String, Integer> actionCounts = new HashMap<>();
        analyticsService.getActionCounts().forEach((action, count) ->
            actionCounts.put(action, count.get()));
        summary.put("actionCounts", actionCounts);

        return summary;
    }
}

Step 7: Running and Testing the Microservices

Now that we have created all our microservices, let’s set up a Docker Compose file to run the required infrastructure (PostgreSQL and Kafka).

Create a docker-compose.yml file in the root directory:

version: "3.8"

services:
  zookeeper:
    image: confluentinc/cp-zookeeper:7.3.0
    container_name: zookeeper
    environment:
      ZOOKEEPER_CLIENT_PORT: 2181
      ZOOKEEPER_TICK_TIME: 2000
    ports:
      - "2181:2181"

  kafka:
    image: confluentinc/cp-kafka:7.3.0
    container_name: kafka
    depends_on:
      - zookeeper
    ports:
      - "9092:9092"
    environment:
      KAFKA_BROKER_ID: 1
      KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092
      KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
      KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
      KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1

  postgres:
    image: postgres:14-alpine
    container_name: postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_MULTIPLE_DATABASES: bookdb,userdb
    ports:
      - "5432:5432"
    volumes:
      - postgres-data:/var/lib/postgresql/data
      - ./init-multiple-dbs.sh:/docker-entrypoint-initdb.d/init-multiple-dbs.sh

volumes:
  postgres-data:

Create a script to initialize multiple databases:

# init-multiple-dbs.sh
#!/bin/bash

set -e
set -u

function create_database() {
    local database=$1
    echo "Creating database '$database'"
    psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" <<-EOSQL
        CREATE DATABASE $database;
        GRANT ALL PRIVILEGES ON DATABASE $database TO $POSTGRES_USER;
EOSQL
}

if [ -n "$POSTGRES_MULTIPLE_DATABASES" ]; then
    echo "Multiple database creation requested: $POSTGRES_MULTIPLE_DATABASES"
    for db in $(echo $POSTGRES_MULTIPLE_DATABASES | tr ',' ' '); do
        create_database $db
    done
    echo "Multiple databases created"
fi

Make the script executable:

chmod +x init-multiple-dbs.sh

Now let’s build and run our microservices in the correct order:

  1. Start the infrastructure:
docker-compose up -d
  1. Build the parent project:
cd book-catalog-microservices
mvn clean install
  1. Start the Discovery Service:
cd discovery-service
mvn spring-boot:run
  1. Start the Config Service:
cd config-service
mvn spring-boot:run
  1. Start the User Service:
cd user-service
mvn spring-boot:run
  1. Start the Book Service:
cd book-service
mvn spring-boot:run
  1. Start the Analytics Service:
cd analytics-service
mvn spring-boot:run

Step 8: Testing the Microservices

Now let’s test our microservices using curl or Postman:

  1. Create a new user:
curl -X POST http://localhost:8082/api/users -H "Content-Type: application/json" -d '{"username":"john_doe","email":"[email protected]","password":"password123"}'
  1. Create a book for the user:
curl -X POST http://localhost:8081/api/books -H "Content-Type: application/json" -d '{"title":"Spring Microservices in Action","author":"John Carnell","isbn":"9781617293986","price":49.99,"userId":1}'
  1. Get all books:
curl http://localhost:8081/api/books
  1. Get books for a specific user:
curl http://localhost:8081/api/books/user/1
  1. Check analytics:
curl http://localhost:8083/api/analytics/summary
  1. Check Eureka Dashboard: Open a browser and go to http://localhost:8761 to see the registered services.

Summary

Congratulations! In this module, we’ve successfully:

  1. Created a microservices architecture for our Book Catalog application
  2. Implemented service discovery using Netflix Eureka
  3. Set up centralized configuration with Spring Cloud Config
  4. Developed service-to-service communication using Feign clients
  5. Maintained event-driven communication between services with Kafka
  6. Handled cross-cutting concerns like analytics in separate microservices

This architecture provides several benefits:

  • Scalability: Each service can be scaled independently
  • Resilience: Failures in one service don’t affect others
  • Development Agility: Teams can work on different services independently
  • Technology Flexibility: Each service can use its own stack
  • Deployment Freedom: Services can be deployed and updated independently

In the next module, we’ll enhance our microservices architecture by adding an API Gateway to provide a unified entry point to our system and implement advanced patterns like circuit breakers for improved fault tolerance.


By Wahid Hamdi