Building Better Microservices with Spring Boot 3
In this tutorial we’ll go through step-by-step building microservices with Spring Boot 3.x features.
Contents
- 1 Setting Up Your Project
- 2 Creating Product Entity
- 3 Creating Product DTOs
- 4 Creating Product Repository
- 5 Creating Product Mapper
- 6 Creating Exception Handling
- 7 Creating Product Service
- 8 Creating Product Controller
- 9 Building and Running
- 10 Testing the Application
- 11 Testing with MockMvc (Integration Test)
- 12 Conclusion
Setting Up Your Project
First, let’s create a new Spring Boot project. Go to Spring Initializr and select:
- Project: Maven
- Language: Java
- Spring Boot: 3.5.3 (or latest)
- Java: 17 or 21
- Packaging: Jar
- Name: Product Service
- Description: Modern microservice for product management
Here’s our complete pom.xml
dependencies with explanations:
<dependencies>
<!-- Web features for REST APIs -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Database operations -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Input validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Health checks and monitoring -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<!-- API documentation -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.8.9</version>
</dependency>
<!-- Object mapping (better than ModelMapper) -->
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.6.3</version>
</dependency>
<!-- Code Generation and Boilerplate Reduction -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Database Driver -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
- spring-boot-starter-web: Creates REST APIs and handles HTTP requests
- spring-boot-starter-data-jpa: Manages database operations with JPA/Hibernate
- spring-boot-starter-validation: Validates input data using annotations like
@NotNull
- spring-boot-starter-actuator: Provides health checks and monitoring endpoints
- springdoc-openapi: Generates interactive API documentation (Swagger UI)
- mapstruct: Converts between objects (Entity to DTO) efficiently
- lombok: Reduces boilerplate code (auto-generates getters, setters, constructors)
- h2: In-memory database for development and testing
Project Structure
After creating the project, organize it following these best practices:
src/main/java/com/example/productservice/
├── ProductServiceApplication.java
├── config/ # Configuration classes
├── controller/ # REST controllers
├── dto/ # Data Transfer Objects
├── entity/ # JPA entities
├── repository/ # Data access layer
├── service/ # Business logic
├── mapper/ # MapStruct mappers
└── exception/ # Custom exceptions
Basic Configuration
Now let’s configure our application with the essential settings. Spring Boot uses the application.yml
file to configure database connections, server settings, and monitoring features.
server:
port: 8080
spring:
application:
name: product-service
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
datasource:
url: jdbc:h2:mem:productdb
driver-class-name: org.h2.Driver
username: sa
password: password
jpa:
hibernate:
ddl-auto: create-drop
show-sql: true
properties:
hibernate:
dialect: org.hibernate.dialect.H2Dialect
format_sql: true
h2:
console:
enabled: true
cache:
type: simple
cache-names: products
actuator:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: always
management:
endpoints:
web:
exposure:
include: health,info,metrics
logging:
level:
com.example.product: DEBUG
org.springframework.web: INFO
springdoc:
api-docs:
enabled: true
path: /api-docs
swagger-ui:
enabled: true
path: /swagger-ui.html
spring.cache
Here we use Spring’s built-in simple in-memory caching. This enables a lightweight caching mechanism using a ConcurrentHashMap
.
In this tutorial, we’ll define a cache named "products"
, which is typically used to store the results of product-related queries—e.g., caching data returned by methods annotated with @Cacheable("products")
. This reduces repetitive database calls and improves performance for frequently accessed product data.
spring.boot.actuator
Next, we enable Spring Boot Actuator, which allows us to monitor and manage our application. With this configuration, we are e exposing some useful endpoints like /actuator/health
, /actuator/info
, /actuator/metrics
, and /actuator/prometheus
.
These endpoints give insights into the application’s health status, runtime metrics, build info, and can be used by Prometheus for monitoring.
springdoc
We also enables the auto-generation of OpenAPI docs at /api-docs
and Swagger UI at /swagger-ui.html
to interactively explore our API.
Creating Product Entity
Let’s create a modern Product entity with better validation and features:
@Entity
@Table(name = "products", indexes = {
@Index(name = "idx_product_store", columnList = "storeId"),
@Index(name = "idx_product_category", columnList = "category"),
@Index(name = "idx_product_uuid", columnList = "productUuid")
})
@EntityListeners(AuditingEntityListener.class)
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = false)
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Builder.Default
@Column(name = "product_uuid", unique = true, nullable = false)
private String productUuid = UUID.randomUUID().toString();
@Column(name = "title", nullable = false)
@NotBlank(message = "Product title cannot be empty")
@Size(min = 2, max = 100, message = "Title must be between 2 and 100 characters")
private String title;
@Column(name = "description", columnDefinition = "TEXT")
@Size(max = 1000, message = "Description cannot exceed 1000 characters")
private String description;
@Column(name = "price", nullable = false, precision = 10, scale = 2)
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than 0")
@DecimalMax(value = "99999.99", message = "Price is too high")
private BigDecimal price;
@Column(name = "store_id", nullable = false)
@NotBlank(message = "Store ID is required")
private String storeId;
@Column(name = "category")
@NotBlank(message = "Category is required")
private String category;
@Column(name = "stock", nullable = false)
@NotNull(message = "Stock quantity is required")
@Min(value = 0, message = "Stock cannot be negative")
private Integer stock;
@Builder.Default
@Column(name = "active", nullable = false)
private Boolean active = true;
@CreationTimestamp
@Column(name = "created_at", nullable = false, updatable = false)
private LocalDateTime createdAt;
@UpdateTimestamp
@Column(name = "updated_at", nullable = false)
private LocalDateTime updatedAt;
}
Database Indexes
In this entity, we define indexes on storeId
, category
, and productUuid
for better query performance when searching by these fields.
Lombok Annotations
Then, we use Lombok annotations like @Data
, @Builder
, @NoArgsConstructor
, and @AllArgsConstructor
. This tells Spring to automatically generate getters, setters, constructors, toString()
, equals()
, and hashCode()
methods, reducing the boilerplate code significantly.
@Builder.Default
This is used for fields that have default values (like productUuid
and active
) to ensure the builder pattern works correctly with these defaults.
Spring Data Validation Annotations
For each attribute, we use Spring Data validation annotations like @NotBlank
, @Size
, @DecimalMin
, @DecimalMax
, @Min
, and @NotNull
to define validation rules that automatically enforce data integrity constraints when the entity is persisted or updated.
Creating Product DTOs
Let’s create DTOs (Data Transfer Objects) that are more secure and user-friendly:
// DTO for creating new products
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Product creation request")
public class CreateProductRequest {
@NotBlank(message = "Product title is required")
@Size(min = 2, max = 100, message = "Title must be between 2 and 100 characters")
@Schema(description = "Product title", example = "iPhone 15 Pro")
private String title;
@Size(max = 1000, message = "Description cannot exceed 1000 characters")
@Schema(description = "Product description", example = "Latest iPhone with advanced features")
private String description;
@NotNull(message = "Price is required")
@DecimalMin(value = "0.01", message = "Price must be greater than 0")
@DecimalMax(value = "99999.99", message = "Price is too high")
@Schema(description = "Product price", example = "999.99")
private BigDecimal price;
@NotBlank(message = "Store ID is required")
@Pattern(regexp = "^[A-Z0-9-]+$", message = "Store ID must contain only uppercase letters, numbers, and hyphens")
@Schema(description = "Store identifier", example = "STORE-001")
private String storeId;
@NotBlank(message = "Category is required")
@Schema(description = "Product category", example = "Electronics")
private String category;
@NotNull(message = "Stock quantity is required")
@Min(value = 0, message = "Stock cannot be negative")
@Max(value = 10000, message = "Stock quantity is too high")
@Schema(description = "Available stock", example = "50")
private Integer stock;
}
// DTO for updating products
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Product update request")
public class UpdateProductRequest {
@Size(min = 2, max = 100, message = "Title must be between 2 and 100 characters")
@Schema(description = "Product title", example = "iPhone 15 Pro Updated")
private String title;
@Size(max = 1000, message = "Description cannot exceed 1000 characters")
@Schema(description = "Product description")
private String description;
@DecimalMin(value = "0.01", message = "Price must be greater than 0")
@DecimalMax(value = "99999.99", message = "Price is too high")
@Schema(description = "Product price", example = "899.99")
private BigDecimal price;
@Min(value = 0, message = "Stock cannot be negative")
@Max(value = 10000, message = "Stock quantity is too high")
@Schema(description = "Available stock", example = "25")
private Integer stock;
}
// DTO for returning product data
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Product response")
public class ProductResponse {
@Schema(description = "Product UUID", example = "550e8400-e29b-41d4-a716-446655440000")
private String productUuid;
@Schema(description = "Product title", example = "iPhone 15 Pro")
private String title;
@Schema(description = "Product description")
private String description;
@Schema(description = "Product price", example = "999.99")
private BigDecimal price;
@Schema(description = "Store identifier", example = "STORE-001")
private String storeId;
@Schema(description = "Product category", example = "Electronics")
private String category;
@Schema(description = "Available stock", example = "50")
private Integer stock;
@Schema(description = "Product status", example = "true")
private Boolean active;
@JsonProperty("created_at")
@Schema(description = "Creation timestamp")
private LocalDateTime createdAt;
@JsonProperty("updated_at")
@Schema(description = "Last update timestamp")
private LocalDateTime updatedAt;
}
We use DTOs (Data Transfer Objects) to separate our internal database structure from external API communication. DTOs provide security by hiding internal database IDs from users.
They offer flexibility with different validation rules for create and update operations. For example, create requests have stricter validation than update requests. This makes our API more secure and user-friendly.
Each DTO has @Schema
annotations that provide clear documentation and examples for API consumers.
Creating Product Repository
Next, we create a repository interface that extends JpaRepository
to handle all database operations for our Product
entity. The repository provides built-in CRUD methods and allows us to define custom query methods using Spring Data JPA conventions.
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findByProductUuid(String productUuid);
List<Product> findByStoreIdAndActiveTrue(String storeId);
Page<Product> findByCategoryAndActiveTrue(String category, Pageable pageable);
@Query("SELECT p FROM Product p WHERE LOWER(p.title) LIKE LOWER(CONCAT('%', :title, '%')) AND p.active = true")
List<Product> searchByTitle(@Param("title") String title);
List<Product> findByPriceBetweenAndActiveTrue(BigDecimal minPrice, BigDecimal maxPrice);
@Query("SELECT p FROM Product p WHERE p.stock < :threshold AND p.active = true")
List<Product> findLowStockProducts(@Param("threshold") Integer threshold);
long countByStoreIdAndActiveTrue(String storeId);
@Modifying
@Query("UPDATE Product p SET p.stock = :stock WHERE p.productUuid = :uuid")
int updateStock(@Param("uuid") String uuid, @Param("stock") Integer stock);
@Modifying
@Query("UPDATE Product p SET p.active = false WHERE p.productUuid = :uuid")
int deactivateProduct(@Param("uuid") String uuid);
boolean existsByProductUuidAndActiveTrue(String productUuid);
}
We use method naming patterns like findByStoreIdAndActiveTrue
for simple queries, and @Query
annotations for complex operations like searching by title or updating stock quantities.
For large sets of data, we can use Pageable
which allows us to specify page size, page number, and sorting criteria to break large result sets into manageable chunks.
Creating Product Mapper
Instead of manually converting between entities and DTOs, we use MapStruct which is faster and safer than ModelMapper. MapStruct generates mapping code at compile time rather than runtime, providing better performance and compile-time error checking.
@Mapper(componentModel = "spring")
public interface ProductMapper {
ProductResponse toResponse(Product product);
List<ProductResponse> toResponseList(List<Product> products);
Product toEntity(CreateProductRequest request);
@BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
void updateEntityFromRequest(UpdateProductRequest request, @MappingTarget Product product);
}
We create a mapper interface with @Mapper(componentModel = "spring")
to automatically generate Spring beans. The mapper handles converting entities to DTOs, DTOs to entities, and updating existing entities from request DTOs using @BeanMapping
to ignore null fields during updates.
Creating Exception Handling
Let’s create proper custom exceptions and global error handling:
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(String message) {
super(message);
}
}
public class InsufficientStockException extends RuntimeException {
public InsufficientStockException(String message) {
super(message);
}
}
public class InvalidProductDataException extends RuntimeException {
public InvalidProductDataException(String message) {
super(message);
}
}
Now let’s create a error response DTO:
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "Error response")
public class ErrorResponse {
@Schema(description = "Error code", example = "PRODUCT_NOT_FOUND")
private String errorCode;
@Schema(description = "Error message", example = "Product not found with UUID: 123")
private String message;
@Schema(description = "Error details")
private List<String> details;
@Schema(description = "Timestamp when error occurred")
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime timestamp;
@Schema(description = "Request path where error occurred", example = "/api/products/123")
private String path;
// other constructors..
}
We’ll create a global exception handler using @RestControllerAdvice
:
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(ProductNotFoundException.class)
public ResponseEntity<ErrorResponse> handleProductNotFound(
ProductNotFoundException ex, HttpServletRequest request) {
logger.error("Product not found error: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
"PRODUCT_NOT_FOUND",
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(InsufficientStockException.class)
public ResponseEntity<ErrorResponse> handleInsufficientStock(
InsufficientStockException ex, HttpServletRequest request) {
logger.error("Insufficient stock error: {}", ex.getMessage());
ErrorResponse error = new ErrorResponse(
"INSUFFICIENT_STOCK",
ex.getMessage(),
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex, HttpServletRequest request) {
logger.error("Validation error: {}", ex.getMessage());
List<String> errors = ex.getBindingResult().getFieldErrors()
.stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.toList());
ErrorResponse error = new ErrorResponse(
"VALIDATION_ERROR",
"Invalid input data",
errors,
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGeneralException(
Exception ex, HttpServletRequest request) {
logger.error("Unexpected error: ", ex);
ErrorResponse error = new ErrorResponse(
"INTERNAL_SERVER_ERROR",
"An unexpected error occurred",
request.getRequestURI()
);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error);
}
}
The @RestControllerAdvice
is Spring’s modern way to handle exceptions globally. It catches exceptions from all controllers and provides consistent error responses.
We also structure all errors to follow the same format with error codes, messages, and timestamps. This makes it easier for client applications to handle errors.
Creating Product Service
We implement the service layer with @Service
and @Transactional
annotations to handle business logic and database transactions:
@Service
@Transactional
public class ProductService {
private static final Logger logger = LoggerFactory.getLogger(ProductService.class);
private final ProductRepository productRepository;
private final ProductMapper productMapper;
@Autowired
public ProductService(ProductRepository productRepository, ProductMapper productMapper) {
this.productRepository = productRepository;
this.productMapper = productMapper;
}
public ProductResponse createProduct(CreateProductRequest request) {
logger.info("Creating new product with title: {}", request.getTitle());
Product product = productMapper.toEntity(request);
Product savedProduct = productRepository.save(product);
logger.info("Product created successfully with UUID: {}", savedProduct.getProductUuid());
return productMapper.toResponse(savedProduct);
}
@Transactional(readOnly = true)
public Page<ProductResponse> getAllProducts(int page, int size, String sortBy, String sortDirection) {
logger.info("Fetching products - page: {}, size: {}, sort: {} {}", page, size, sortBy, sortDirection);
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
Pageable pageable = PageRequest.of(page, size, sort);
Page<Product> products = productRepository.findAll(pageable);
return products.map(productMapper::toResponse);
}
@Transactional(readOnly = true)
@Cacheable(value = "products", key = "#uuid")
public ProductResponse getProductByUuid(String uuid) {
logger.info("Fetching product with UUID: {}", uuid);
Product product = productRepository.findByProductUuid(uuid)
.orElseThrow(() -> new ProductNotFoundException("Product not found with UUID: " + uuid));
return productMapper.toResponse(product);
}
@Transactional(readOnly = true)
public List<ProductResponse> searchProducts(String title) {
logger.info("Searching products with title containing: {}", title);
List<Product> products = productRepository.searchByTitle(title);
return productMapper.toResponseList(products);
}
@CacheEvict(value = "products", key = "#uuid")
public ProductResponse updateProduct(String uuid, UpdateProductRequest request) {
logger.info("Updating product with UUID: {}", uuid);
Product product = productRepository.findByProductUuid(uuid)
.orElseThrow(() -> new ProductNotFoundException("Product not found with UUID: " + uuid));
productMapper.updateEntityFromRequest(request, product);
Product updatedProduct = productRepository.save(product);
logger.info("Product updated successfully with UUID: {}", uuid);
return productMapper.toResponse(updatedProduct);
}
@CacheEvict(value = "products", key = "#uuid")
public void reduceStock(String uuid, Integer quantity) {
logger.info("Reducing stock for product UUID: {} by quantity: {}", uuid, quantity);
Product product = productRepository.findByProductUuid(uuid)
.orElseThrow(() -> new ProductNotFoundException("Product not found with UUID: " + uuid));
if (product.getStock() < quantity) {
throw new InsufficientStockException(
"Insufficient stock. Available: " + product.getStock() + ", Requested: " + quantity);
}
product.setStock(product.getStock() - quantity);
productRepository.save(product);
logger.info("Stock reduced successfully for product UUID: {}", uuid);
}
@CacheEvict(value = "products", key = "#uuid")
public void deleteProduct(String uuid) {
logger.info("Deleting product with UUID: {}", uuid);
int updatedRows = productRepository.deactivateProduct(uuid);
if (updatedRows == 0) {
throw new ProductNotFoundException("Product not found with UUID: " + uuid);
}
logger.info("Product deleted successfully with UUID: {}", uuid);
}
}
In this example, we added logging to track operations and use @Transactional(readOnly = true)
for read operations to optimize performance.
The service includes caching with @Cacheable
for frequently accessed data and @CacheEvict
to clear cache when data changes.
We handle pagination using PageRequest
and Sort
objects, and implement proper error handling by throwing custom exceptions when products are not found or when there’s insufficient stock.
Creating Product Controller
Let’s create a controller which implements a complete CRUD (Create, Read, Update, Delete) API for product management:
@RestController
@RequestMapping("/api/v1/products")
@Tag(name = "Product Management", description = "APIs for managing products")
@Validated
public class ProductController {
private final ProductService productService;
@Autowired
public ProductController(ProductService productService) {
this.productService = productService;
}
@PostMapping
@Operation(summary = "Create a new product")
@ApiResponses(value = {
@ApiResponse(responseCode = "201", description = "Product created successfully"),
@ApiResponse(responseCode = "400", description = "Invalid input data")
})
public ResponseEntity<ProductResponse> createProduct(
@Valid @RequestBody CreateProductRequest request) {
ProductResponse response = productService.createProduct(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
@GetMapping
@Operation(summary = "Get all products with pagination")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Products retrieved successfully")
})
public ResponseEntity<Page<ProductResponse>> getAllProducts(
@Parameter(description = "Page number (0-based)")
@RequestParam(defaultValue = "0") @Min(0) int page,
@Parameter(description = "Page size")
@RequestParam(defaultValue = "10") @Min(1) int size,
@Parameter(description = "Sort field")
@RequestParam(defaultValue = "createdAt") String sortBy,
@Parameter(description = "Sort direction")
@RequestParam(defaultValue = "desc") String sortDirection) {
Page<ProductResponse> products = productService.getAllProducts(page, size, sortBy, sortDirection);
return ResponseEntity.ok(products);
}
@GetMapping("/{uuid}")
@Operation(summary = "Get product by UUID")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Product found"),
@ApiResponse(responseCode = "404", description = "Product not found")
})
public ResponseEntity<ProductResponse> getProduct(
@Parameter(description = "Product UUID")
@PathVariable String uuid) {
ProductResponse product = productService.getProductByUuid(uuid);
return ResponseEntity.ok(product);
}
@PutMapping("/{uuid}")
@Operation(summary = "Update product")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "Product updated successfully"),
@ApiResponse(responseCode = "404", description = "Product not found"),
@ApiResponse(responseCode = "400", description = "Invalid input data")
})
public ResponseEntity<ProductResponse> updateProduct(
@Parameter(description = "Product UUID")
@PathVariable String uuid,
@Valid @RequestBody UpdateProductRequest request) {
ProductResponse response = productService.updateProduct(uuid, request);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{uuid}")
@Operation(summary = "Delete product (soft delete)")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "Product deleted successfully"),
@ApiResponse(responseCode = "404", description = "Product not found")
})
public ResponseEntity<Void> deleteProduct(
@Parameter(description = "Product UUID")
@PathVariable String uuid) {
productService.deleteProduct(uuid);
return ResponseEntity.noContent().build();
}
@GetMapping("/search")
@Operation(summary = "Search products by title")
public ResponseEntity<List<ProductResponse>> searchProducts(
@Parameter(description = "Search term")
@RequestParam String title) {
List<ProductResponse> products = productService.searchProducts(title);
return ResponseEntity.ok(products);
}
}
The @RestController
annotation tells Spring that this class will handle HTTP requests and return JSON responses directly, while @RequestMapping("/api/v1/products")
establishes the base URL path for all endpoints.
We use @Operation
and @ApiResponse
annotations to generate automatic API documentation. Which means when we run the application, we’ll get a Swagger UI interface to test our endpoints.

Building and Running
To build and run this Spring application, we use standard Maven commands. These steps will compile the source code, run unit tests, and package the application into a deployable JAR.
Use the commands below:
# Clean and compile
mvn clean compile
# Run tests
mvn test
# Package the application
mvn clean package
# Skip tests during packaging
mvn clean package -DskipTests
mvn spring-boot:run
Testing the Application
We use a set of cURL
commands to test the Product
API endpoints. Make sure the backend server is running at http://localhost:8080
.
Access Points
- Application: http://localhost:8080
- Swagger UI: http://localhost:8080/swagger-ui.html
- API Docs: http://localhost:8080/api-docs
- H2 Console: http://localhost:8080/h2-console
cURL Test Commands
1. Create a Product
Use the following POST
request to create a new product in the system. This request sends a JSON body with product details:
curl -X POST http://localhost:8080/api/v1/products \
-H "Content-Type: application/json" \
-d '{
"name": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"category": "Electronics",
"storeId": "store-001",
"stock": 50
}'
We expect to receive an error response because the store ID does not pass validation:
{
"errorCode": "VALIDATION_ERROR",
"message": "Invalid input data",
"details": [
"Store ID must contain only uppercase letters, numbers, and hyphens",
"Product title is required"
],
"timestamp": "2025-07-14 09:23:15",
"path": "/api/v1/products"
}
Let’s modify our cURL command:
curl -X POST http://localhost:8080/api/v1/products \
-H "Content-Type: application/json" \
-d '{
"title": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"category": "Electronics",
"storeId": "STORE-001",
"stock": 50
}'
Now, we should see a successful response return the created product, including its UUID:
{
"productUuid": "6a23a9c3-483b-4234-9e29-baa7add4c0a2",
"title": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"storeId": "STORE-001",
"category": "Electronics",
"stock": 50,
"active": true,
"created_at": "2025-07-14T09:26:35.681936",
"updated_at": "2025-07-14T09:26:35.682004"
}
2. Get All Products (with pagination)
Then retrieve all products using a simple GET
request. Optionally, you may include pagination, sorting, and filtering parameters.
# Basic request
curl -X GET http://localhost:8080/api/v1/products
# With pagination parameters
curl -X GET "http://localhost:8080/api/v1/products?page=0&size=5&sortBy=name&sortDirection=asc"
3. Get Product by UUID
To retrieve a specific product, use its UUID returned from the create API. Replace {uuid}
with the actual product UUID.
# Replace {uuid} with actual UUID from create response
curl -X GET http://localhost:8080/api/v1/products/{uuid}
4. Update Product
Use the PUT
method to update product details. Replace {uuid}
with the actual product UUID.
# Replace {uuid} with actual UUID
curl -X PUT http://localhost:8080/api/v1/products/{uuid} \
-H "Content-Type: application/json" \
-d '{
"name": "Gaming Laptop",
"description": "High-performance gaming laptop",
"price": 1299.99,
"category": "Electronics",
"stock": 30
}'
Testing with MockMvc (Integration Test)
To ensure our product REST API endpoints work correctly, we can write integration tests using Spring’s MockMvc. This allows us to perform HTTP requests and verify responses without starting a real web server, making tests fast and reliable.
@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private ProductService productService;
@Autowired
private ObjectMapper objectMapper;
private CreateProductRequest createProductRequest;
private UpdateProductRequest updateProductRequest;
private Product testProduct;
@BeforeEach
void setUp() {
createProductRequest = new CreateProductRequest(
"iPhone 15 Pro",
"Latest iPhone with advanced features",
new BigDecimal("999.99"),
"STORE-001",
"Electronics",
50
);
updateProductRequest = new UpdateProductRequest(
"iPhone 15 Pro Updated",
"Updated description",
new BigDecimal("899.99"),
25
);
testProduct = new Product(
"iPhone 15 Pro",
"Latest iPhone with advanced features",
new BigDecimal("999.99"),
"STORE-001",
"Electronics",
50
);
testProduct.setProductUuid("550e8400-e29b-41d4-a716-446655440000");
testProduct.setActive(true);
testProduct.setCreatedAt(LocalDateTime.now());
testProduct.setUpdatedAt(LocalDateTime.now());
}
@Test
void createProduct_Success() throws Exception {
when(productService.createProduct(any(CreateProductRequest.class)))
.thenReturn(mapToProductResponse(testProduct));
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createProductRequest)))
.andExpect(status().isCreated())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.productUuid").value("550e8400-e29b-41d4-a716-446655440000"))
.andExpect(jsonPath("$.title").value("iPhone 15 Pro"))
.andExpect(jsonPath("$.description").value("Latest iPhone with advanced features"))
.andExpect(jsonPath("$.price").value(999.99))
.andExpect(jsonPath("$.storeId").value("STORE-001"))
.andExpect(jsonPath("$.category").value("Electronics"))
.andExpect(jsonPath("$.stock").value(50))
.andExpect(jsonPath("$.active").value(true))
.andExpect(jsonPath("$.created_at").exists())
.andExpect(jsonPath("$.updated_at").exists());
verify(productService).createProduct(any(CreateProductRequest.class));
}
@Test
void createProduct_ValidationError_BlankTitle() throws Exception {
createProductRequest.setTitle("");
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createProductRequest)))
.andExpect(status().isBadRequest());
verify(productService, never()).createProduct(org.mockito.Mockito.any(CreateProductRequest.class));
}
@Test
void createProduct_ValidationError_InvalidPrice() throws Exception {
createProductRequest.setPrice(new BigDecimal("0"));
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createProductRequest)))
.andExpect(status().isBadRequest());
verify(productService, never()).createProduct(any(CreateProductRequest.class));
}
@Test
void createProduct_ValidationError_NegativeStock() throws Exception {
createProductRequest.setStock(-1);
mockMvc.perform(post("/api/v1/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(createProductRequest)))
.andExpect(status().isBadRequest());
verify(productService, never()).createProduct(any(CreateProductRequest.class));
}
@Test
void getAllProducts_Success() throws Exception {
List<com.cloudfullstack.product.dto.ProductResponse> products = Arrays.asList(
mapToProductResponse(testProduct)
);
Page<com.cloudfullstack.product.dto.ProductResponse> page =
new PageImpl<>(products, PageRequest.of(0, 10), 1);
when(productService.getAllProducts(0, 10, "createdAt", "desc"))
.thenReturn(page);
mockMvc.perform(get("/api/v1/products")
.param("page", "0")
.param("size", "10")
.param("sortBy", "createdAt")
.param("sortDirection", "desc"))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.content", hasSize(1)))
.andExpect(jsonPath("$.content[0].productUuid").value("550e8400-e29b-41d4-a716-446655440000"))
.andExpect(jsonPath("$.content[0].title").value("iPhone 15 Pro"))
.andExpect(jsonPath("$.totalElements").value(1))
.andExpect(jsonPath("$.totalPages").value(1))
.andExpect(jsonPath("$.number").value(0))
.andExpect(jsonPath("$.size").value(10));
verify(productService).getAllProducts(0, 10, "createdAt", "desc");
}
@Test
void getProduct_Success() throws Exception {
String uuid = "550e8400-e29b-41d4-a716-446655440000";
when(productService.getProductByUuid(uuid))
.thenReturn(mapToProductResponse(testProduct));
mockMvc.perform(get("/api/v1/products/{uuid}", uuid))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.productUuid").value(uuid))
.andExpect(jsonPath("$.title").value("iPhone 15 Pro"))
.andExpect(jsonPath("$.price").value(999.99))
.andExpect(jsonPath("$.storeId").value("STORE-001"));
verify(productService).getProductByUuid(uuid);
}
@Test
void updateProduct_Success() throws Exception {
String uuid = "550e8400-e29b-41d4-a716-446655440000";
Product updatedProduct = new Product(
"iPhone 15 Pro Updated",
"Updated description",
new BigDecimal("899.99"),
"STORE-001",
"Electronics",
25
);
updatedProduct.setProductUuid(uuid);
updatedProduct.setActive(true);
updatedProduct.setCreatedAt(LocalDateTime.now());
updatedProduct.setUpdatedAt(LocalDateTime.now());
when(productService.updateProduct(eq(uuid), any(UpdateProductRequest.class)))
.thenReturn(mapToProductResponse(updatedProduct));
mockMvc.perform(put("/api/v1/products/{uuid}", uuid)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateProductRequest)))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.productUuid").value(uuid))
.andExpect(jsonPath("$.title").value("iPhone 15 Pro Updated"))
.andExpect(jsonPath("$.description").value("Updated description"))
.andExpect(jsonPath("$.price").value(899.99))
.andExpect(jsonPath("$.stock").value(25));
verify(productService).updateProduct(eq(uuid), any(UpdateProductRequest.class));
}
@Test
void updateProduct_ValidationError_InvalidPrice() throws Exception {
String uuid = "550e8400-e29b-41d4-a716-446655440000";
updateProductRequest.setPrice(new BigDecimal("0"));
mockMvc.perform(put("/api/v1/products/{uuid}", uuid)
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(updateProductRequest)))
.andExpect(status().isBadRequest());
verify(productService, never()).updateProduct(anyString(), any(UpdateProductRequest.class));
}
@Test
void deleteProduct_Success() throws Exception {
String uuid = "550e8400-e29b-41d4-a716-446655440000";
doNothing().when(productService).deleteProduct(uuid);
mockMvc.perform(delete("/api/v1/products/{uuid}", uuid))
.andExpect(status().isNoContent());
verify(productService).deleteProduct(uuid);
}
@Test
void getProductsByStore_Success() throws Exception {
String storeId = "STORE-001";
List<com.cloudfullstack.product.dto.ProductResponse> products = Arrays.asList(
mapToProductResponse(testProduct)
);
when(productService.getProductsByStore(storeId))
.thenReturn(products);
mockMvc.perform(get("/api/v1/products/store/{storeId}", storeId))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].productUuid").value("550e8400-e29b-41d4-a716-446655440000"))
.andExpect(jsonPath("$[0].storeId").value(storeId));
verify(productService).getProductsByStore(storeId);
}
@Test
void searchProducts_Success() throws Exception {
String searchTerm = "iPhone";
List<com.cloudfullstack.product.dto.ProductResponse> products = Arrays.asList(
mapToProductResponse(testProduct)
);
when(productService.searchProducts(searchTerm))
.thenReturn(products);
mockMvc.perform(get("/api/v1/products/search")
.param("title", searchTerm))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$", hasSize(1)))
.andExpect(jsonPath("$[0].title").value(containsString("iPhone")));
verify(productService).searchProducts(searchTerm);
}
@Test
void updateStock_Success() throws Exception {
String uuid = "550e8400-e29b-41d4-a716-446655440000";
Integer newStock = 100;
doNothing().when(productService).updateStock(uuid, newStock);
mockMvc.perform(post("/api/v1/products/{uuid}/stock", uuid)
.param("quantity", newStock.toString()))
.andExpect(status().isOk());
verify(productService).updateStock(uuid, newStock);
}
@Test
void updateStock_ValidationError_NegativeQuantity() throws Exception {
String uuid = "550e8400-e29b-41d4-a716-446655440000";
Integer negativeStock = -1;
mockMvc.perform(post("/api/v1/products/{uuid}/stock", uuid)
.param("quantity", negativeStock.toString()))
.andExpect(status().isBadRequest());
verify(productService, never()).updateStock(anyString(), anyInt());
}
private com.cloudfullstack.product.dto.ProductResponse mapToProductResponse(Product product) {
return new com.cloudfullstack.product.dto.ProductResponse(
product.getProductUuid(),
product.getTitle(),
product.getDescription(),
product.getPrice(),
product.getStoreId(),
product.getCategory(),
product.getStock(),
product.getActive(),
product.getCreatedAt(),
product.getUpdatedAt()
);
}
}

Conclusion
In this article, we learned how to build modern, production-ready microservices using Spring Boot 3.x with industry best practices. We covered the complete development lifecycle from initial setup to testing. We also explored advanced features like Spring Boot Actuator for monitoring, caching for performance optimization, and SpringDoc for API documentation.
In the next article, we will cover CI/CD pipelines using GitHub Actions and dockerize our Spring Boot application to deploy it to AWS Fargate. We’ll go through how to create automated build and deployment workflows, containerize our microservice with Docker, and deploy it to a fully managed container platform in the cloud for production-ready scalability.
The full source code can be found in GitHub.