[Java Backend Zero to Hello] [Phase 8] BÀI 8.3: PRODUCT & CATEGORY MODULE
📚 Series: Java Backend Zero to Hello 📂 Phân đoạn: Phase 8: Project thực tế 📖 Nội dung: BÀI 8.3: PRODUCT & CATEGORY MODULE 💡 Khóa học lập trình Backend Java & Spring Boot chuẩn doanh nghiệp từ con số 0.
BÀI 8.3: PRODUCT & CATEGORY MODULE
Mục tiêu
- Implement Category với phân cấp
- Implement Product CRUD
- Upload ảnh sản phẩm
- Search, filter, pagination
1. CATEGORY MODULE
1.1 Entity
@Entity
@Table(name = "categories")
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 100)
private String name;
@Column(unique = true, length = 100)
private String slug;
@Column(columnDefinition = "TEXT")
private String description;
@Column(name = "image_url")
private String imageUrl;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private Category parent;
@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL)
private List<Category> subcategories = new ArrayList<>();
@Column(name = "sort_order")
private Integer sortOrder = 0;
@Column(nullable = false)
private boolean active = true;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
}
1.2 Repository
@Repository
public interface CategoryRepository extends JpaRepository<Category, Long> {
Optional<Category> findBySlug(String slug);
List<Category> findByParentIsNull();
List<Category> findByParentId(Long parentId);
boolean existsBySlug(String slug);
}
1.3 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class CategoryService {
private final CategoryRepository categoryRepository;
private final CategoryMapper categoryMapper;
public List<CategoryResponse> findAll() {
return categoryRepository.findAll().stream()
.map(categoryMapper::toResponse)
.toList();
}
public List<CategoryResponse> findRootCategories() {
return categoryRepository.findByParentIsNull().stream()
.map(categoryMapper::toResponse)
.toList();
}
public CategoryResponse findById(Long id) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Category not found"));
return categoryMapper.toResponse(category);
}
@Transactional
public CategoryResponse create(CreateCategoryRequest request) {
if (categoryRepository.existsBySlug(request.slug())) {
throw new DuplicateException("Slug already exists");
}
Category category = categoryMapper.toEntity(request);
if (request.parentId() != null) {
Category parent = categoryRepository.findById(request.parentId())
.orElseThrow(() -> new NotFoundException("Parent category not found"));
category.setParent(parent);
}
Category saved = categoryRepository.save(category);
log.info("Category created: {}", saved.getName());
return categoryMapper.toResponse(saved);
}
@Transactional
public CategoryResponse update(Long id, UpdateCategoryRequest request) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Category not found"));
categoryMapper.updateEntity(request, category);
Category saved = categoryRepository.save(category);
return categoryMapper.toResponse(saved);
}
@Transactional
public void delete(Long id) {
Category category = categoryRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Category not found"));
if (!category.getSubcategories().isEmpty()) {
throw new BusinessException("CATEGORY_HAS_CHILDREN",
"Cannot delete category with subcategories");
}
categoryRepository.delete(category);
}
}
1.4 Controller
@RestController
@RequestMapping("/categories")
@RequiredArgsConstructor
@Tag(name = "Category", description = "Category APIs")
public class CategoryController {
private final CategoryService categoryService;
@GetMapping
@Operation(summary = "Get all categories")
public ApiResponse<List<CategoryResponse>> getAll() {
return ApiResponse.success(categoryService.findAll());
}
@GetMapping("/roots")
@Operation(summary = "Get root categories")
public ApiResponse<List<CategoryResponse>> getRoots() {
return ApiResponse.success(categoryService.findRootCategories());
}
@GetMapping("/{id}")
@Operation(summary = "Get category by ID")
public ApiResponse<CategoryResponse> getById(@PathVariable Long id) {
return ApiResponse.success(categoryService.findById(id));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Create category (Admin)")
public ResponseEntity<ApiResponse<CategoryResponse>> create(
@RequestBody @Valid CreateCategoryRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Category created", categoryService.create(request)));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update category (Admin)")
public ApiResponse<CategoryResponse> update(
@PathVariable Long id,
@RequestBody @Valid UpdateCategoryRequest request) {
return ApiResponse.success(categoryService.update(id, request));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Delete category (Admin)")
public ApiResponse<Void> delete(@PathVariable Long id) {
categoryService.delete(id);
return ApiResponse.success("Category deleted", null);
}
}
2. PRODUCT MODULE
2.1 Entity
@Entity
@Table(name = "products")
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(unique = true, nullable = false)
private String slug;
@Column(columnDefinition = "TEXT")
private String description;
@Column(nullable = false, precision = 12, scale = 2)
private BigDecimal price;
@Column(name = "sale_price", precision = 12, scale = 2)
private BigDecimal salePrice;
@Column(nullable = false)
private Integer stock = 0;
@Column(unique = true, length = 50)
private String sku;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id", nullable = false)
private Category category;
@OneToMany(mappedBy = "product", cascade = CascadeType.ALL, orphanRemoval = true)
@OrderBy("sortOrder ASC")
private List<ProductImage> images = new ArrayList<>();
@Column(nullable = false)
private boolean active = true;
@Column(nullable = false)
private boolean featured = false;
@CreatedDate
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@LastModifiedDate
@Column(name = "updated_at")
private LocalDateTime updatedAt;
public void addImage(ProductImage image) {
images.add(image);
image.setProduct(this);
}
public void removeImage(ProductImage image) {
images.remove(image);
image.setProduct(null);
}
}
2.2 ProductImage Entity
@Entity
@Table(name = "product_images")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class ProductImage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(name = "image_url", nullable = false)
private String imageUrl;
@Column(name = "is_primary")
private boolean primary = false;
@Column(name = "sort_order")
private Integer sortOrder = 0;
}
2.3 Repository
@Repository
public interface ProductRepository extends JpaRepository<Product, Long>,
JpaSpecificationExecutor<Product> {
Optional<Product> findBySlug(String slug);
boolean existsBySlug(String slug);
boolean existsBySku(String sku);
@Query("SELECT p FROM Product p WHERE p.active = true ORDER BY p.createdAt DESC")
List<Product> findRecentProducts(Pageable pageable);
@Query("SELECT p FROM Product p WHERE p.featured = true AND p.active = true")
List<Product> findFeaturedProducts(Pageable pageable);
}
2.4 Specifications
public class ProductSpecifications {
public static Specification<Product> hasName(String name) {
return (root, query, cb) ->
name == null ? null : cb.like(cb.lower(root.get("name")),
"%" + name.toLowerCase() + "%");
}
public static Specification<Product> hasCategory(Long categoryId) {
return (root, query, cb) ->
categoryId == null ? null : cb.equal(root.get("category").get("id"), categoryId);
}
public static Specification<Product> priceBetween(BigDecimal min, BigDecimal max) {
return (root, query, cb) -> {
if (min == null && max == null) return null;
if (min == null) return cb.lessThanOrEqualTo(root.get("price"), max);
if (max == null) return cb.greaterThanOrEqualTo(root.get("price"), min);
return cb.between(root.get("price"), min, max);
};
}
public static Specification<Product> isActive() {
return (root, query, cb) -> cb.isTrue(root.get("active"));
}
public static Specification<Product> isFeatured() {
return (root, query, cb) -> cb.isTrue(root.get("featured"));
}
}
2.5 Service
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepository;
private final CategoryRepository categoryRepository;
private final ProductMapper productMapper;
private final FileStorageService fileStorageService;
public Page<ProductResponse> findAll(ProductSearchCriteria criteria, Pageable pageable) {
Specification<Product> spec = Specification.where(ProductSpecifications.isActive());
if (criteria.name() != null) {
spec = spec.and(ProductSpecifications.hasName(criteria.name()));
}
if (criteria.categoryId() != null) {
spec = spec.and(ProductSpecifications.hasCategory(criteria.categoryId()));
}
if (criteria.minPrice() != null || criteria.maxPrice() != null) {
spec = spec.and(ProductSpecifications.priceBetween(
criteria.minPrice(), criteria.maxPrice()));
}
if (criteria.featured() != null && criteria.featured()) {
spec = spec.and(ProductSpecifications.isFeatured());
}
return productRepository.findAll(spec, pageable)
.map(productMapper::toResponse);
}
public ProductResponse findById(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found"));
return productMapper.toResponse(product);
}
@Transactional
public ProductResponse create(CreateProductRequest request) {
if (productRepository.existsBySlug(request.slug())) {
throw new DuplicateException("Slug already exists");
}
Product product = productMapper.toEntity(request);
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new NotFoundException("Category not found"));
product.setCategory(category);
Product saved = productRepository.save(product);
log.info("Product created: {}", saved.getName());
return productMapper.toResponse(saved);
}
@Transactional
public ProductResponse update(Long id, UpdateProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found"));
productMapper.updateEntity(request, product);
if (request.categoryId() != null) {
Category category = categoryRepository.findById(request.categoryId())
.orElseThrow(() -> new NotFoundException("Category not found"));
product.setCategory(category);
}
Product saved = productRepository.save(product);
return productMapper.toResponse(saved);
}
@Transactional
public void delete(Long id) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException("Product not found"));
productRepository.delete(product);
}
@Transactional
public ProductResponse addImage(Long productId, MultipartFile file) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new NotFoundException("Product not found"));
String imageUrl = fileStorageService.save(file);
ProductImage image = ProductImage.builder()
.imageUrl(imageUrl)
.primary(product.getImages().isEmpty())
.sortOrder(product.getImages().size())
.build();
product.addImage(image);
Product saved = productRepository.save(product);
return productMapper.toResponse(saved);
}
}
2.6 Controller
@RestController
@RequestMapping("/products")
@RequiredArgsConstructor
@Tag(name = "Product", description = "Product APIs")
public class ProductController {
private final ProductService productService;
@GetMapping
@Operation(summary = "Get all products")
public ApiResponse<PageResponse<ProductResponse>> getAll(
@RequestParam(required = false) String name,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) BigDecimal minPrice,
@RequestParam(required = false) BigDecimal maxPrice,
@RequestParam(required = false) Boolean featured,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "desc") String direction) {
ProductSearchCriteria criteria = new ProductSearchCriteria(
name, categoryId, minPrice, maxPrice, featured
);
Sort.Direction dir = Sort.Direction.fromString(direction);
Pageable pageable = PageRequest.of(page, size, Sort.by(dir, sortBy));
Page<ProductResponse> products = productService.findAll(criteria, pageable);
return ApiResponse.success(PageResponse.from(products));
}
@GetMapping("/{id}")
@Operation(summary = "Get product by ID")
public ApiResponse<ProductResponse> getById(@PathVariable Long id) {
return ApiResponse.success(productService.findById(id));
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Create product (Admin)")
public ResponseEntity<ApiResponse<ProductResponse>> create(
@RequestBody @Valid CreateProductRequest request) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success("Product created", productService.create(request)));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update product (Admin)")
public ApiResponse<ProductResponse> update(
@PathVariable Long id,
@RequestBody @Valid UpdateProductRequest request) {
return ApiResponse.success(productService.update(id, request));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Delete product (Admin)")
public ApiResponse<Void> delete(@PathVariable Long id) {
productService.delete(id);
return ApiResponse.success("Product deleted", null);
}
@PostMapping(value = "/{id}/images", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Upload product image (Admin)")
public ApiResponse<ProductResponse> uploadImage(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
return ApiResponse.success(productService.addImage(id, file));
}
}
3. CACHING
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
@Cacheable(value = "products", key = "#id")
public ProductResponse findById(Long id) {
log.info("Fetching product from DB: {}", id);
// ...
}
@CacheEvict(value = "products", key = "#id")
public ProductResponse update(Long id, UpdateProductRequest request) {
// ...
}
@CacheEvict(value = "products", allEntries = true)
public void clearCache() { }
}
4. TESTING
@DataJpaTest
class ProductRepositoryTest {
@Autowired private ProductRepository productRepository;
@Autowired private TestEntityManager entityManager;
@Test
void shouldFindBySlug() {
Category category = Category.builder().name("Electronics").build();
entityManager.persistAndFlush(category);
Product product = Product.builder()
.name("Laptop")
.slug("laptop")
.price(BigDecimal.valueOf(1000))
.stock(10)
.category(category)
.build();
entityManager.persistAndFlush(product);
Optional<Product> found = productRepository.findBySlug("laptop");
assertThat(found).isPresent();
assertThat(found.get().getName()).isEqualTo("Laptop");
}
}
5. BÀI TẬP
Bài 1: Category
Implement đầy đủ Category CRUD với phân cấp.
Bài 2: Product
Implement Product CRUD với search, filter.
Bài 3: Upload
Implement upload ảnh sản phẩm.
Bài 4: Cache
Áp dụng caching cho Product.
Bài tiếp theo: 8.4 Cart & Order Module
🧭 Điều hướng bài học
- ⬅️ Bài trước: BÀI 8.2: AUTHENTICATION MODULE
- ➡️ Bài tiếp theo: BÀI 8.4: CART & ORDER MODULE
- 📑 Toàn bộ lộ trình: Java Backend Zero to Hello
⭐ Hãy bookmark (clip) lại series để tiện theo dõi các bài học tiếp theo nhé!
All rights reserved