Creating Microservices with Spring Boot
Microservices are an architectural style that structures an application as a collection of loosely coupled services. These services are designed to be small, independently deployable, and capable of performing a specific set of tasks. In this guide, we’ll explore how to build robust microservices using Spring Boot, a powerful framework that simplifies Java development. We will learn how to create a microservice project from scratch, set up the necessary components, and implement CRUD (Create, Read, Update, Delete) operations.
1. Introduction
In this tutorial, we’ll explore four primary components that constitute a microservice:
1.1 Entity
We will define an Entity
, which represents the core data model. In this tutorial, our Entity
will be the Product
, and it will include attributes like ID
, name
, and store ID
.
1.2 Repository
To interact with the database and manage data operations efficiently, we will implement a Repository
. In our case, we’ll leverage Spring Data JPA to create the ProductRepository
interface. Spring Data JPA provides a convenient way to perform database operations, including the essential CRUD (Create, Read, Update, Delete) operations.
1.3 Service
The service layer is where the business logic of our microservice resides. We will create a Service
to manage the operations on the Product
data. This service will define how our microservice handles tasks like fetching all products, creating new products, updating existing ones, and deleting products.
1.4 Controller
We will implement the ProductController
to handle HTTP requests. This controller will expose RESTful endpoints that clients can use to interact with our microservice.
2. Setting Up the Development Environment
Creating a new Spring Boot project becomes straightforward using the Spring Initializr. Visit the Spring Initializr website, and choose the necessary dependencies like Spring Web, Spring Data JPA, and MySQL Driver. Next, click ‘Generate’ to obtain the project template.
2.1 Adding Necessary Dependencies
In the pom.xml
file, make sure we have the following dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.modelmapper</groupId>
<artifactId>modelmapper</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<scope>runtime</scope>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter-api</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
2.2 Configuring Maven with MySQL
To link the Maven with MySQL, we’ll need to configure the database connection in the application.properties
file:
spring.datasource.url=jdbc:mysql://localhost:3306/your_database
spring.datasource.username=root
spring.datasource.password=root
spring.jpa.hibernate.ddl-auto=update
The spring.jpa.hibernate.ddl-auto
property with the value update
tells Hibernate to update the database schema based on the entity model changes detected in the application. This means that Hibernate will compare the existing database schema to the entity model and update the schema as needed to match the entity model. This can include adding or removing tables, columns, and constraints.
3. Building a Simple RESTful API with Spring Boot
3.1 Creating Entities
We’ll start by defining our Product entity class.
@Entity
@Table(name = "products")
@EntityListeners(AuditingEntityListener.class)
@JsonIgnoreProperties(value = { "createdAt", "updatedAt" }, allowGetters = true)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "title")
private String title;
@Column(name = "description")
private String description;
@Column(name = "price")
private Float price;
@Column(name = "storeId")
private String storeId;
@Column(name = "stock")
private Integer stock;
@Column(nullable = false, updatable = false)
@Temporal(TemporalType.TIMESTAMP)
@CreatedDate
private Date createdAt;
@Column(nullable = false)
@Temporal(TemporalType.TIMESTAMP)
@LastModifiedDate
private Date updatedAt;
// Getters and Setters
}
The @EntityListeners(AuditingEntityListener.class)
annotation tells Spring Data JPA to use the AuditingEntityListener
class to audit changes to our entity. The AuditingEntityListener
class is a callback listener that is invoked before an entity is persisted or updated.
When the AuditingEntityListener
class is invoked, it will set the following fields on the entity:
createdDate
: The date and time the entity was created.lastModifiedDate
: The date and time the entity was last modified.createdBy
: The user who created the entity.lastModifiedBy
: The user who last modified the entity.
The createdBy
and lastModifiedBy
will be automatically populated if Spring Security is available in the project.
3.2 Creating Data Transfer Object (DTO)
Instead of directly utilizing the entity class for requests, consider creating a DTO that includes only the fields meant for updates. This approach enables us to skip validation for fields that the user shouldn’t modify.
public class ProductDto {
@NotNull(message = "Title must not be null")
@Size(min = 2, max = 255)
private String title;
private String description;
@NotNull(message = "Price must not be null")
@Min(0)
private Double price;
@NotNull(message = "Store Id must not be null")
private String storeId;
@NotNull(message = "Stock number must not be null")
private Integer stock;
// Constructors, Getters and Setters
}
3.3 Creating Repositories
We’ll create a Spring Data JPA repository interface for our Product entity.
public interface ProductRepository extends CrudRepository<Product, Long> {
List<Product> findByStoreId(String storeId);
}
3.4 Creating Services
We’ll create a service class to implement the business logic for our product CRUD operations.
@Service
public class ProductServiceImpl implements ProductService{
@Autowired
ProductRepository productRepository;
@Override
public ProductDto createProduct(ProductDto productDto) {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
Product product = modelMapper.map(productDto, Product.class);
productRepository.save(product);
return modelMapper.map(product, ProductDto.class);
}
@Override
public List<ProductDto> getAllProducts() {
List<Product> productList = (List<Product>) productRepository.findAll();
Type listType = new TypeToken<List<ProductDto>>() {}.getType();
return new ModelMapper().map(productList, listType);
}
@Override
public List<ProductDto> getProductsByStoreId(String storeId) {
List<Product> productList = productRepository.findByStoreId(storeId);
Type listType = new TypeToken<List<ProductDto>>() {}.getType();
return new ModelMapper().map(productList, listType);
}
@Override
public ProductDto getProductById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
return new ModelMapper().map(product, ProductDto.class);
}
@Override
public ProductDto updateProduct(Long id, ProductDto productDetails) {
ModelMapper modelMapper = new ModelMapper();
modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
product.setTitle(productDetails.getTitle());
product.setDescription(productDetails.getDescription());
product.setPrice(productDetails.getPrice());
product.setStoreId(productDetails.getStoreId());
productRepository.save(product);
return modelMapper.map(product, ProductDto.class);
}
@Override
public void deleteProduct(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found"));
productRepository.delete(product);
}
}
The ModelMapper
is a library that simplifies the conversion between different Java objects, such as entities and DTOs. We use MatchingStrategies.STRICT
matching strategy for the mapping. The strict matching strategy ensures the automatic mapping of fields with the same name and type.
3.4 Creating Controllers
We’ll develop a controller class to specify the RESTful endpoints responsible for our CRUD (Create, Read, Update, Delete) operations.
@RestController
@RequestMapping("/api/products")
public class ProductController {
@Autowired
ProductService productService;
@GetMapping
public ResponseEntity<Map<String, Object>> getAllProducts() {
List<ProductDto> products = productService.getAllProducts();
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.OK.value());
response.put("message", PRODUCTS_RETRIEVED_SUCCESS);
response.put("data", products);
return ResponseEntity.ok(response);
}
@GetMapping("/store/{storeId}")
public ResponseEntity<Map<String, Object>> getProductsByStoreId(@PathVariable String storeId) {
List<ProductDto> products = productService.getProductsByStoreId(storeId);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.OK.value());
response.put("message", PRODUCTS_STORE_RETRIEVED_SUCCESS);
response.put("data", products);
return ResponseEntity.ok(response);
}
@GetMapping("/{id}")
public ResponseEntity<Map<String, Object>> getProductById(@PathVariable Long id) {
try {
ProductDto product = productService.getProductById(id);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.OK.value());
response.put("message", PRODUCTS_RETRIEVED_SUCCESS);
response.put("data", product);
return ResponseEntity.ok(response);
} catch (ResourceNotFoundException e) {
Map<String, Object> response = new HashMap<>();
response.put("status", "error");
response.put("statusCode", HttpStatus.NOT_FOUND.value());
response.put("message", PRODUCT_NOT_FOUND);
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
}
@PostMapping
public ResponseEntity<Map<String, Object>> createProduct(@Valid @RequestBody ProductDto product) {
ProductDto createdProduct = productService.createProduct(product);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.CREATED.value());
response.put("message", PRODUCT_CREATED_SUCCESS);
response.put("data", createdProduct);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
@PutMapping("/{id}")
public ResponseEntity<Map<String, Object>> updateProduct(@PathVariable Long id, @Valid @RequestBody ProductDto productDetails) {
try {
ProductDto updatedProduct = productService.updateProduct(id, productDetails);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.OK.value());
response.put("message", PRODUCT_UPDATED_SUCCESS);
response.put("data", updatedProduct);
return ResponseEntity.ok(response);
} catch (ResourceNotFoundException e) {
Map<String, Object> response = new HashMap<>();
response.put("status", "error");
response.put("statusCode", HttpStatus.NOT_FOUND.value());
response.put("message", PRODUCT_NOT_FOUND);
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
}
@DeleteMapping("/{id}")
public ResponseEntity<Map<String, Object>> deleteProduct(@PathVariable Long id) {
try {
productService.deleteProduct(id);
Map<String, Object> response = new HashMap<>();
response.put("status", "success");
response.put("statusCode", HttpStatus.NO_CONTENT.value());
response.put("message", PRODUCT_DELETED_SUCCESS);
return new ResponseEntity<>(response, HttpStatus.NO_CONTENT);
} catch (ResourceNotFoundException e) {
Map<String, Object> response = new HashMap<>();
response.put("status", "error");
response.put("statusCode", HttpStatus.NOT_FOUND.value());
response.put("message", PRODUCT_NOT_FOUND);
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
}
}
4. Logging and Exception Handling
4.1 Configuring Logging
Spring Boot provides excellent support for logging. We can configure logging levels in the application.properties
file:
logging.level.root=WARN
logging.level.com.yourpackage=DEBUG
Replace com.yourpackage
with the actual package name for which we want to set the logging level.
Spring Boot uses Logback as its default logging library. We can configure Logback either using the logback-spring.xml
file or by adding properties directly to the application.properties
file, as shown in our example.
Here’s an example of how we can use a logger in our service class:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class ProductServiceImpl implements ProductService {
private static final Logger logger = LoggerFactory.getLogger(ProductService.class);
// ... other methods ...
public ProductDto getProductById(Long id) {
logger.info("Fetching product with ID: {}", id);
// ... rest of the method ...
}
}
4.2 Implementing Custom Exception Handling
For a more user-friendly experience, we can create custom exception handling. Here’s how we can do it:
Create a custom exception class, such as ResourceNotFoundException
. This exception can be thrown when a requested resource is not found:
public class ResourceNotFoundException extends RuntimeException {
public ResourceNotFoundException(String message) {
super(message);
}
}
2. Next, let’s create a custom exception response class:
public class CustomErrorResponse {
private String message;
private int status;
// Getters and Setters
}
3. We’ll create a global exception handler using @ControllerAdvice
:
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<CustomErrorResponse> handleResourceNotFoundException(ResourceNotFoundException ex, WebRequest request) {
CustomErrorResponse response = new CustomErrorResponse(ex.getMessage(), HttpStatus.NOT_FOUND.value());
return new ResponseEntity<>(response, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<CustomErrorResponse> handleGenericException(Exception ex) {
CustomErrorResponse response = new CustomErrorResponse(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR.value());
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
// Add more exception handlers as needed...
}
The GlobalExceptionHandler
comes into play when an exception is thrown from a controller method and not explicitly handled within that method. If we want the ResourceNotFoundException
to be handled by the global exception handler, we need to remove the try-catch block from the controller
method. Let the exception propagate up to the controller advice, and it will be appropriately handled there.
5. Testing with JUnit and Mokito
Testing is essential to ensure the quality of our microservices. We’ll write unit tests using JUnit and Mockito to test our product microservices thoroughly.
First, create a new test class for ProductServiceImpl
. Name it something like ProductServiceImplTest
. In the test class, we use Mockito to mock the dependencies that ProductServiceImpl
relies on. For example, if ProductServiceImpl
depends on a repository or another service, mock those components using @MockBean
or @Mock
. The @InjectMocks
annotation is then used to inject these mock objects into the fields of our class under test.
@SpringBootTest
public class ProductServiceImplTest {
@InjectMocks
private ProductServiceImpl productService;
@Mock
private ProductRepository productRepository;
@Test
public void testCreateProduct() {
ProductDto productDto = new ProductDto();
Product product = new Product();
when(productRepository.save(any(Product.class))).thenReturn(product);
ProductDto result = productService.createProduct(productDto);
assertNotNull(result);
}
@Test
public void testGetAllProducts() {
List<Product> products = Arrays.asList(new Product());
when(productRepository.findAll()).thenReturn(products);
List<ProductDto> result = productService.getAllProducts();
assertEquals(1, result.size());
}
@Test
public void testGetProductsByStoreId() {
List<Product> products = Arrays.asList(new Product());
when(productRepository.findByStoreId(anyString())).thenReturn(products);
List<ProductDto> result = productService.getProductsByStoreId("storeId");
assertEquals(1, result.size());
}
@Test
public void testGetProductById() {
Product product = new Product();
when(productRepository.findById(anyLong())).thenReturn(Optional.of(product));
ProductDto result = productService.getProductById(1L);
assertNotNull(result);
}
...
}
Next, we create a new test class for ProductController
. Name it something like ProductControllerTest
. The @AutoConfigureMockMvc
annotation in Spring Boot is used for unit testing web applications using the MockMvc framework. MockMvc
is a Spring framework class that allows us to perform HTTP requests against our application’s controllers without starting a full server. It provides methods to simulate HTTP requests (such as GET, POST, etc.) and verify the responses.
@SpringBootTest
@AutoConfigureMockMvc
public class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@InjectMocks
private ProductController productController;
@Mock
private ProductService productService;
private ProductDto productDto;
private Long productId = 1L;
@BeforeEach
public void setUp() {
reset(productService); // Reset the mock behavior
productDto = new ProductDto("Product title", "Product description", 349.90, "S-4", 100);
when(productService.createProduct(any(ProductDto.class))).thenReturn(productDto);
when(productService.getProductById(productId)).thenReturn(productDto); // Return the original productDto
when(productService.updateProduct(eq(productId), any(ProductDto.class))).thenReturn(new ProductDto("Updated title", "Updated description", 299.90, "S-4", 50)); // Return a new instance with updated values
doNothing().when(productService).deleteProduct(productId);
}
@Test
public void testGetAllProducts() throws Exception {
mockMvc.perform(get("/api/products"))
.andExpect(status().isOk());
}
@Test
public void testGetProductsByStoreId() throws Exception {
mockMvc.perform(get("/api/products/store/storeId"))
.andExpect(status().isOk());
}
@Test
public void testGetProductById() throws Exception {
Long productId = 1L;
mockMvc.perform(get("/api/products/" + productId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.title", is(productDto.getTitle())))
.andExpect(jsonPath("$.data.description", is(productDto.getDescription())))
.andExpect(jsonPath("$.data.price", is(productDto.getPrice())))
.andExpect(jsonPath("$.data.storeId", is(productDto.getStoreId())))
.andExpect(jsonPath("$.data.stock", is(productDto.getStock())));
}
@Test
public void testGetProductByIdNotFound() throws Exception {
when(productService.getProductById(anyLong())).thenThrow(new ResourceNotFoundException("Product not found"));
mockMvc.perform(get("/api/products/1"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.status", is("error")))
.andExpect(jsonPath("$.message", is("Product not found!")));
}
...
}
6. Managing Different Environments
We often need different configurations for various environments such as development, testing, and production. Spring Boot simplifies the management of these configurations.
6.1 Creating Environment-Specific Configuration Files
To manage environment-specific configurations, we can create separate property files for each environment:
application-dev.properties
for development.application-test.properties
for testing.application-prod.properties
for production.
Inside these files, we can define environment-specific properties, like database connections, logging levels, and other configurations relevant to that environment.
6.2 Setting Up Profiles
In Spring Boot, we use a profile to activate specific configurations based on the environment. We can set the active profile in several ways:
6.2.1 In the application.properties
File:
In the application.properties
file of the project, specify the active profile using:
spring.profiles.active=dev
6.2.2 As a Command-Line Argument:
When running our Spring Boot application, we can specify the active profile as a command-line argument. For example, to activate the prod
profile:
java -jar your-app.jar --spring.profiles.active=prod
6.2.3 In the Code (e.g., in a Test Class):
We can also activate profiles programmatically in the code. For instance, in a test class, use the @ActiveProfiles
annotation to activate the test
profile:
@ActiveProfiles("test")
public class MyTest {
// Test methods go here
}
6.2.4 Using Maven Command:
We’re using Maven to run our Spring Boot application, we can specify the active profile as an argument:
mvn spring-boot:run -Dspring-boot.run.arguments=--spring.profiles.active=dev
Activating the appropriate profile ensures that we use the correct configurations for each environment. For example, using spring.profiles.active=dev
will load configurations from application-dev.properties
.
7. Conclusion
In this comprehensive tutorial, we’ve explored essential concepts and practices for building robust RESTful APIs. From setting up the development environment to implementing CRUD operations. Full source code is available on GitHub. In the next tutorial, we will walk through inter-service communication in a Spring Boot application using Spring Cloud OpenFeign.
Share this content:
Leave a Comment