From b8195e71e024174c6724fefa5aae7c9a8d43a722 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Sun, 25 Jan 2026 13:01:30 +0900 Subject: [PATCH 1/4] feat(product): add Product domain layer - Add ProductModel entity with name, description, price, stock fields - Add ProductRepository interface for domain abstraction - Add ProductService with CRUD business logic Co-Authored-By: Claude Opus 4.5 --- .../loopers/domain/product/ProductModel.java | 82 +++++++++++++++++++ .../domain/product/ProductRepository.java | 14 ++++ .../domain/product/ProductService.java | 48 +++++++++++ 3 files changed, 144 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java new file mode 100644 index 0000000..4d9dca1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -0,0 +1,82 @@ +package com.loopers.domain.product; + +import com.loopers.domain.BaseEntity; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import java.math.BigDecimal; + +@Entity +@Table(name = "product") +public class ProductModel extends BaseEntity { + + private String name; + private String description; + private BigDecimal price; + private Integer stock; + + protected ProductModel() {} + + public ProductModel(String name, String description, BigDecimal price, Integer stock) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + } + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public BigDecimal getPrice() { + return price; + } + + public Integer getStock() { + return stock; + } + + public void update(String name, String description, BigDecimal price, Integer stock) { + if (name == null || name.isBlank()) { + throw new CoreException(ErrorType.BAD_REQUEST, "상품명은 비어있을 수 없습니다."); + } + if (price == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (price.compareTo(BigDecimal.ZERO) < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0 이상이어야 합니다."); + } + if (stock == null) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + if (stock < 0) { + throw new CoreException(ErrorType.BAD_REQUEST, "재고는 0 이상이어야 합니다."); + } + + this.name = name; + this.description = description; + this.price = price; + this.stock = stock; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java new file mode 100644 index 0000000..79357d7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -0,0 +1,14 @@ +package com.loopers.domain.product; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ProductRepository { + Optional find(Long id); + List findAll(); + Page findAll(Pageable pageable); + ProductModel save(ProductModel product); + void delete(ProductModel product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java new file mode 100644 index 0000000..953a535 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -0,0 +1,48 @@ +package com.loopers.domain.product; + +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductService { + + private final ProductRepository productRepository; + + @Transactional + public ProductModel createProduct(String name, String description, BigDecimal price, Integer stock) { + ProductModel product = new ProductModel(name, description, price, stock); + return productRepository.save(product); + } + + @Transactional(readOnly = true) + public ProductModel getProduct(Long id) { + return productRepository.find(id) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "[id = " + id + "] 상품을 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public Page getProducts(Pageable pageable) { + return productRepository.findAll(pageable); + } + + @Transactional + public ProductModel updateProduct(Long id, String name, String description, BigDecimal price, Integer stock) { + ProductModel product = getProduct(id); + product.update(name, description, price, stock); + return product; + } + + @Transactional + public void deleteProduct(Long id) { + ProductModel product = getProduct(id); + productRepository.delete(product); + } +} From 6ffd495a0b3777938966046c65ab4cdd8e0bc325 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Sun, 25 Jan 2026 13:02:51 +0900 Subject: [PATCH 2/4] feat(product): add Product infrastructure layer - Add ProductJpaRepository extending Spring Data JPA - Add ProductRepositoryImpl implementing domain repository - Support soft delete with findAllByDeletedAtIsNull Co-Authored-By: Claude Opus 4.5 --- .../product/ProductJpaRepository.java | 10 ++++ .../product/ProductRepositoryImpl.java | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java new file mode 100644 index 0000000..a8e661f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -0,0 +1,10 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductJpaRepository extends JpaRepository { + Page findAllByDeletedAtIsNull(Pageable pageable); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java new file mode 100644 index 0000000..d2ad4fa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -0,0 +1,46 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@RequiredArgsConstructor +@Component +public class ProductRepositoryImpl implements ProductRepository { + private final ProductJpaRepository productJpaRepository; + + @Override + public Optional find(Long id) { + return productJpaRepository.findById(id); + } + + @Override + public List findAll() { + return productJpaRepository.findAll().stream() + .filter(product -> product.getDeletedAt() == null) + .collect(Collectors.toList()); + } + + @Override + public Page findAll(Pageable pageable) { + return productJpaRepository.findAllByDeletedAtIsNull(pageable); + } + + @Override + public ProductModel save(ProductModel productModel) { + return productJpaRepository.save(productModel); + } + + @Override + public void delete(ProductModel productModel) { + productModel.delete(); + productJpaRepository.save(productModel); + } +} From 79971e6f01218252be427e8a2e72d7b3497e2ae4 Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Sun, 25 Jan 2026 13:03:30 +0900 Subject: [PATCH 3/4] feat(product): add Product application layer - Add ProductInfo record for application DTO - Add ProductFacade for coordinating service calls Co-Authored-By: Claude Opus 4.5 --- .../application/product/ProductFacade.java | 40 +++++++++++++++++++ .../application/product/ProductInfo.java | 20 ++++++++++ 2 files changed, 60 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java new file mode 100644 index 0000000..4a82983 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -0,0 +1,40 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.product.ProductService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; + +@RequiredArgsConstructor +@Component +public class ProductFacade { + private final ProductService productService; + + public ProductInfo createProduct(String name, String description, BigDecimal price, Integer stock) { + ProductModel product = productService.createProduct(name, description, price, stock); + return ProductInfo.from(product); + } + + public ProductInfo getProduct(Long id) { + ProductModel product = productService.getProduct(id); + return ProductInfo.from(product); + } + + public Page getProducts(Pageable pageable) { + Page products = productService.getProducts(pageable); + return products.map(ProductInfo::from); + } + + public ProductInfo updateProduct(Long id, String name, String description, BigDecimal price, Integer stock) { + ProductModel product = productService.updateProduct(id, name, description, price, stock); + return ProductInfo.from(product); + } + + public void deleteProduct(Long id) { + productService.deleteProduct(id); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java new file mode 100644 index 0000000..bb20224 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java @@ -0,0 +1,20 @@ +package com.loopers.application.product; + +import com.loopers.domain.product.ProductModel; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; + +public record ProductInfo(Long id, String name, String description, BigDecimal price, Integer stock, ZonedDateTime createdAt, ZonedDateTime updatedAt) { + public static ProductInfo from(ProductModel model) { + return new ProductInfo( + model.getId(), + model.getName(), + model.getDescription(), + model.getPrice(), + model.getStock(), + model.getCreatedAt(), + model.getUpdatedAt() + ); + } +} From d85f9db7433e3ffd7feeb136e58c95db35273f4f Mon Sep 17 00:00:00 2001 From: "hanyoung.park" Date: Sun, 25 Jan 2026 13:03:39 +0900 Subject: [PATCH 4/4] feat(product): add Product API interface layer - Add ProductV1Dto with Create/Update requests and responses - Add ProductV1ApiSpec with OpenAPI annotations - Add ProductV1Controller with CRUD endpoints: - POST /api/v1/products (create) - GET /api/v1/products/{id} (read single) - GET /api/v1/products (read list with pagination) - PUT /api/v1/products/{id} (update) - DELETE /api/v1/products/{id} (soft delete) Co-Authored-By: Claude Opus 4.5 --- .../api/product/ProductV1ApiSpec.java | 57 +++++++++++++ .../api/product/ProductV1Controller.java | 81 +++++++++++++++++++ .../interfaces/api/product/ProductV1Dto.java | 70 ++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java new file mode 100644 index 0000000..a31a03b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -0,0 +1,57 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "Product V1 API", description = "상품 관리 API 입니다.") +public interface ProductV1ApiSpec { + + @Operation( + summary = "상품 생성", + description = "새로운 상품을 생성합니다." + ) + ApiResponse createProduct( + ProductV1Dto.CreateRequest request + ); + + @Operation( + summary = "상품 조회", + description = "ID로 상품을 조회합니다." + ) + ApiResponse getProduct( + @Schema(description = "상품 ID") + Long productId + ); + + @Operation( + summary = "상품 목록 조회", + description = "상품 목록을 페이징하여 조회합니다." + ) + ApiResponse getProducts( + @Schema(description = "페이지 번호") + int page, + @Schema(description = "페이지 크기") + int size + ); + + @Operation( + summary = "상품 수정", + description = "상품 정보를 수정합니다." + ) + ApiResponse updateProduct( + @Schema(description = "상품 ID") + Long productId, + ProductV1Dto.UpdateRequest request + ); + + @Operation( + summary = "상품 삭제", + description = "상품을 삭제합니다." + ) + ApiResponse deleteProduct( + @Schema(description = "상품 ID") + Long productId + ); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java new file mode 100644 index 0000000..55b3c65 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -0,0 +1,81 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductFacade; +import com.loopers.application.product.ProductInfo; +import com.loopers.interfaces.api.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.web.bind.annotation.*; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/products") +public class ProductV1Controller implements ProductV1ApiSpec { + + private final ProductFacade productFacade; + + @PostMapping + @Override + public ApiResponse createProduct( + @Valid @RequestBody ProductV1Dto.CreateRequest request + ) { + ProductInfo info = productFacade.createProduct( + request.name(), + request.description(), + request.price(), + request.stock() + ); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping("/{productId}") + @Override + public ApiResponse getProduct( + @PathVariable Long productId + ) { + ProductInfo info = productFacade.getProduct(productId); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @GetMapping + @Override + public ApiResponse getProducts( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size + ) { + PageRequest pageable = PageRequest.of(page, size); + Page productPage = productFacade.getProducts(pageable); + ProductV1Dto.ProductListResponse response = ProductV1Dto.ProductListResponse.from(productPage); + return ApiResponse.success(response); + } + + @PutMapping("/{productId}") + @Override + public ApiResponse updateProduct( + @PathVariable Long productId, + @Valid @RequestBody ProductV1Dto.UpdateRequest request + ) { + ProductInfo info = productFacade.updateProduct( + productId, + request.name(), + request.description(), + request.price(), + request.stock() + ); + ProductV1Dto.ProductResponse response = ProductV1Dto.ProductResponse.from(info); + return ApiResponse.success(response); + } + + @DeleteMapping("/{productId}") + @Override + public ApiResponse deleteProduct( + @PathVariable Long productId + ) { + productFacade.deleteProduct(productId); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java new file mode 100644 index 0000000..25d629e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -0,0 +1,70 @@ +package com.loopers.interfaces.api.product; + +import com.loopers.application.product.ProductInfo; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.data.domain.Page; + +import java.math.BigDecimal; +import java.time.ZonedDateTime; +import java.util.List; + +public class ProductV1Dto { + public record CreateRequest( + @NotBlank String name, + String description, + @NotNull @DecimalMin("0") BigDecimal price, + @NotNull @Min(0) Integer stock + ) {} + + public record UpdateRequest( + @NotBlank String name, + String description, + @NotNull @DecimalMin("0") BigDecimal price, + @NotNull @Min(0) Integer stock + ) {} + + public record ProductResponse( + Long id, + String name, + String description, + BigDecimal price, + Integer stock, + ZonedDateTime createdAt, + ZonedDateTime updatedAt + ) { + public static ProductResponse from(ProductInfo info) { + return new ProductResponse( + info.id(), + info.name(), + info.description(), + info.price(), + info.stock(), + info.createdAt(), + info.updatedAt() + ); + } + } + + public record ProductListResponse( + List products, + int page, + int size, + long totalElements, + int totalPages + ) { + public static ProductListResponse from(Page page) { + return new ProductListResponse( + page.getContent().stream() + .map(ProductResponse::from) + .toList(), + page.getNumber(), + page.getSize(), + page.getTotalElements(), + page.getTotalPages() + ); + } + } +}