Skip to content

How to Build a REST API with Spring Boot: Complete Guide

Purpose

I recently needed to build a REST API for a product catalog, and I wanted to do it right with proper layered architecture, validation, and error handling. In this post, I’ll show you exactly how I built a complete REST API with Spring Boot, from setup to testing.

Here’s what we’ll build:

  • A Product Catalog API with full CRUD operations
  • Proper layered architecture (Controller → Service → Repository)
  • Request validation and exception handling
  • MySQL database integration with JPA
  • Production-ready testing approach

Let’s get started.

Setup

I used Spring Initializr to bootstrap the project quickly. Here’s what I chose:

Project Configuration:

  • Project: Maven
  • Language: Java
  • Spring Boot: 3.2.x
  • Java: 17
  • Packaging: Jar

Essential Dependencies:

  • Spring Web (for REST controllers)
  • Spring Data JPA (for database operations)
  • MySQL Driver (for MySQL connectivity)
  • Validation (for request validation)
  • Lombok (reduces boilerplate code)

Project Structure:

src/main/java/com/example/productapi/
├── controller/
│ └── ProductController.java
├── service/
│ ├── ProductService.java
│ └── ProductServiceImpl.java
├── repository/
│ └── ProductRepository.java
├── entity/
│ └── Product.java
├── exception/
│ └── GlobalExceptionHandler.java
└── ProductApiApplication.java

application.properties:

# Database Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/product_db
spring.datasource.username=root
spring.datasource.password=yourpassword
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

Entity

I created the Product entity with JPA annotations and validation. Here’s the complete entity:

@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Product name is required")
@Size(min = 3, max = 100, message = "Name must be between 3 and 100 characters")
@Column(nullable = false, length = 100)
private String name;
@NotBlank(message = "Description is required")
@Column(nullable = false, columnDefinition = "TEXT")
private String description;
@NotNull(message = "Price is required")
@Positive(message = "Price must be positive")
@Column(nullable = false)
private BigDecimal price;
@NotNull(message = "Quantity is required")
@PositiveOrZero(message = "Quantity cannot be negative")
@Column(nullable = false)
private Integer quantity;
@CreatedDate
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(nullable = false)
private LocalDateTime updatedAt;
}

Key annotations:

  • @Entity marks the class as a JPA entity
  • @GeneratedValue auto-increments the ID
  • @NotBlank, @NotNull, @Positive ensure data integrity
  • @CreatedDate/@LastModifiedDate for auditing

Repository

I built the repository layer using Spring Data JPA. The best part: I got CRUD methods for free.

@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
// Derived query methods
List<Product> findByNameContainingIgnoreCase(String name);
List<Product> findByPriceBetween(BigDecimal minPrice, BigDecimal maxPrice);
List<Product> findByQuantityLessThan(Integer quantity);
// Custom query with @Query
@Query("SELECT p FROM Product p WHERE p.price > :minPrice ORDER BY p.price DESC")
List<Product> findExpensiveProducts(@Param("minPrice") BigDecimal minPrice);
// Exists check
boolean existsByName(String name);
}

Spring Data JPA generates SQL from method names. For example, findByNameContainingIgnoreCase becomes:

SELECT * FROM products WHERE LOWER(name) LIKE LOWER(?)

Service

I created a service layer to separate business logic from controller logic. This makes the code testable and maintainable.

ProductService Interface:

public interface ProductService {
List<Product> getAllProducts();
Optional<Product> getProductById(Long id);
Product createProduct(Product product);
Product updateProduct(Long id, Product productDetails);
void deleteProduct(Long id);
List<Product> searchProducts(String name);
}

ProductServiceImpl:

@Service
@Transactional
public class ProductServiceImpl implements ProductService {
private final ProductRepository repository;
@Autowired
public ProductServiceImpl(ProductRepository repository) {
this.repository = repository;
}
@Override
@Transactional(readOnly = true)
public List<Product> getAllProducts() {
return repository.findAll();
}
@Override
@Transactional(readOnly = true)
public Optional<Product> getProductById(Long id) {
return repository.findById(id);
}
@Override
public Product createProduct(Product product) {
// Check if product with same name exists
if (repository.existsByName(product.getName())) {
throw new ResourceAlreadyExistsException("Product with name '" + product.getName() + "' already exists");
}
return repository.save(product);
}
@Override
public Product updateProduct(Long id, Product productDetails) {
Product product = repository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
// Update fields
product.setName(productDetails.getName());
product.setDescription(productDetails.getDescription());
product.setPrice(productDetails.getPrice());
product.setQuantity(productDetails.getQuantity());
return repository.save(product);
}
@Override
public void deleteProduct(Long id) {
if (!repository.existsById(id)) {
throw new ResourceNotFoundException("Product not found with id: " + id);
}
repository.deleteById(id);
}
@Override
@Transactional(readOnly = true)
public List<Product> searchProducts(String name) {
return repository.findByNameContainingIgnoreCase(name);
}
}

Key points:

  • @Service marks it as a service layer component
  • @Transactional manages database transactions
  • Constructor injection is preferred over field injection
  • Business logic validation happens before database operations

Controller

I built the REST controller with Spring MVC annotations. This layer handles HTTP requests and responses.

@RestController
@RequestMapping("/api/products")
@Validated
public class ProductController {
private final ProductService service;
@Autowired
public ProductController(ProductService service) {
this.service = service;
}
// GET all products
@GetMapping
public ResponseEntity<List<Product>> getAllProducts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
List<Product> products = service.getAllProducts();
return ResponseEntity.ok(products);
}
// GET product by ID
@GetMapping("/{id}")
public ResponseEntity<Product> getProductById(@PathVariable Long id) {
Optional<Product> product = service.getProductById(id);
return product.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// POST create new product
@PostMapping
public ResponseEntity<Product> createProduct(@Valid @RequestBody Product product) {
Product createdProduct = service.createProduct(product);
return ResponseEntity.status(HttpStatus.CREATED).body(createdProduct);
}
// PUT update product
@PutMapping("/{id}")
public ResponseEntity<Product> updateProduct(
@PathVariable Long id,
@Valid @RequestBody Product productDetails) {
Product updatedProduct = service.updateProduct(id, productDetails);
return ResponseEntity.ok(updatedProduct);
}
// DELETE product
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
service.deleteProduct(id);
return ResponseEntity.noContent().build();
}
// GET search products
@GetMapping("/search")
public ResponseEntity<List<Product>> searchProducts(
@RequestParam String name) {
List<Product> products = service.searchProducts(name);
return ResponseEntity.ok(products);
}
}

Key points:

  • @RestController combines @Controller + @ResponseBody
  • @Valid triggers validation on @RequestBody
  • @PathVariable extracts URL path variables
  • @RequestParam extracts query parameters
  • ResponseEntity allows custom status codes

HTTP Methods:

  • GET: Read resources
  • POST: Create new resources
  • PUT: Update existing resources
  • DELETE: Remove resources

Testing

I tested the API with curl commands. Here’s how I verified each endpoint:

Terminal window
# GET all products
curl -X GET http://localhost:8080/api/products
# GET product by ID
curl -X GET http://localhost:8080/api/products/1
# POST create product
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{
"name": "Laptop",
"description": "High-performance laptop",
"price": 999.99,
"quantity": 50
}'
# PUT update product
curl -X PUT http://localhost:8080/api/products/1 \
-H "Content-Type: application/json" \
-d '{
"name": "Laptop Pro",
"description": "Ultra high-performance laptop",
"price": 1299.99,
"quantity": 30
}'
# DELETE product
curl -X DELETE http://localhost:8080/api/products/1
# GET search products
curl -X GET "http://localhost:8080/api/products/search?name=laptop"

Integration Test:

@SpringBootTest
@AutoConfigureMockMvc
class ProductControllerIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ProductRepository repository;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldCreateProduct() throws Exception {
Product product = new Product();
product.setName("Test Product");
product.setDescription("Test Description");
product.setPrice(new BigDecimal("99.99"));
product.setQuantity(10);
mockMvc.perform(post("/api/products")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(product)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.name").value("Test Product"));
}
@Test
void shouldReturnListOfProducts() throws Exception {
mockMvc.perform(get("/api/products"))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isArray());
}
}

Summary

I built a complete REST API with Spring Boot using proper layered architecture. Here’s what I learned:

What worked well:

  • Spring Data JPA eliminates boilerplate CRUD code
  • Validation annotations ensure data integrity
  • Layered architecture keeps code organized
  • Exception handling provides consistent error responses

Best practices I followed:

  • Use @RestController for APIs (not @Controller)
  • Apply validation at the entity level
  • Separate business logic in service layer
  • Use constructor injection over field injection
  • Return proper HTTP status codes (200, 201, 204, 404, 500)

Next steps I’d add:

  • Spring Security for authentication
  • Pagination for large datasets
  • API documentation with Swagger
  • DTOs to separate API models from entities
  • Comprehensive unit and integration tests

This approach gives you a production-ready foundation for building REST APIs with Spring Boot. The layered architecture keeps code maintainable, and Spring Boot’s conventions reduce boilerplate so you can focus on business logic.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments