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:
- Discovery Service: Eureka Server for service discovery
- Config Service: Spring Cloud Config Server for centralized configuration
- Book Service: Core book catalog functionality
- User Service: User management and authentication
- 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:
- Start the infrastructure:
docker-compose up -d
- Build the parent project:
cd book-catalog-microservices
mvn clean install
- Start the Discovery Service:
cd discovery-service
mvn spring-boot:run
- Start the Config Service:
cd config-service
mvn spring-boot:run
- Start the User Service:
cd user-service
mvn spring-boot:run
- Start the Book Service:
cd book-service
mvn spring-boot:run
- 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:
- 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"}'
- 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}'
- Get all books:
curl http://localhost:8081/api/books
- Get books for a specific user:
curl http://localhost:8081/api/books/user/1
- Check analytics:
curl http://localhost:8083/api/analytics/summary
- 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:
- Created a microservices architecture for our Book Catalog application
- Implemented service discovery using Netflix Eureka
- Set up centralized configuration with Spring Cloud Config
- Developed service-to-service communication using Feign clients
- Maintained event-driven communication between services with Kafka
- 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