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.javaapplication.properties:
# Database Configurationspring.datasource.url=jdbc:mysql://localhost:3306/product_dbspring.datasource.username=rootspring.datasource.password=yourpasswordspring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
# JPA Configurationspring.jpa.hibernate.ddl-auto=updatespring.jpa.show-sql=truespring.jpa.properties.hibernate.format_sql=trueEntity
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:
@Entitymarks the class as a JPA entity@GeneratedValueauto-increments the ID@NotBlank,@NotNull,@Positiveensure data integrity@CreatedDate/@LastModifiedDatefor auditing
Repository
I built the repository layer using Spring Data JPA. The best part: I got CRUD methods for free.
@Repositorypublic 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@Transactionalpublic 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:
@Servicemarks it as a service layer component@Transactionalmanages 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")@Validatedpublic 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:
@RestControllercombines@Controller+@ResponseBody@Validtriggers validation on@RequestBody@PathVariableextracts URL path variables@RequestParamextracts query parametersResponseEntityallows 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:
# GET all productscurl -X GET http://localhost:8080/api/products
# GET product by IDcurl -X GET http://localhost:8080/api/products/1
# POST create productcurl -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 productcurl -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 productcurl -X DELETE http://localhost:8080/api/products/1
# GET search productscurl -X GET "http://localhost:8080/api/products/search?name=laptop"Integration Test:
@SpringBootTest@AutoConfigureMockMvcclass 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
@RestControllerfor 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