generated from Loopers-dev-lab/loop-pack-be-l2-vol2-java
-
Notifications
You must be signed in to change notification settings - Fork 43
도메인 주도 설계 구현 #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
hyejin0810
wants to merge
13
commits into
Loopers-dev-lab:hyejin0810
Choose a base branch
from
hyejin0810:main
base: hyejin0810
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
도메인 주도 설계 구현 #103
Changes from all commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
70384e1
docs: 설계 문서 운영/동시성/정합성 개선 사항 반영
hyejin0810 4c59dda
docs: 장바구니 제거 및 잔액(balance) 기능 추가
hyejin0810 72f7ed6
feat: User/Brand/Product/Like/Order 도메인 구현 (TDD)
hyejin0810 a185b6e
docs: Round3 도메인 및 아키텍처 설계 전략 추가
hyejin0810 a89ea33
feat: 좋아요 목록 조회, 브랜드 삭제 연쇄, 주문 스냅샷 구현 및 단위 테스트 추가
2452824
fix: ProductService List import 누락 수정
f535d97
fix: BrandFacade 트랜잭션 누락 및 OrderFacade 주문 소유자 검증 추가
hyejin0810 73514b1
refactor: OrderService.cancelOrder 시그니처를 Order 엔티티를 직접 받도록 변경
hyejin0810 237b1d4
refactor: 불필요한 assertThat 제거
hyejin0810 789f115
feat: UserFacade 추가 및 레이어 위반 수정
hyejin0810 573dede
refactor: @Transactional을 Service에서 Facade로 이동
hyejin0810 9e81b2c
refactor: @Transactional을 Service로 복원
f67c5ab
fix: 코드래빗 수정
hyejin0810 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| ## 도메인 & 객체 설계 전략 | ||
|
|
||
| 비즈니스 규칙 캡슐화: 도메인 객체(Entity, VO)는 데이터만 가진 구조체가 아니라, 자신의 비즈니스 규칙을 스스로 검증하고 수행해야 합니다. | ||
|
|
||
|
|
||
| 애플리케이션 서비스의 역할: 서로 다른 도메인 객체들을 조합하고 로직을 조정(Orchestration)하여 기능을 완성하는 데 집중하며, 핵심 비즈니스 로직은 도메인으로 위임합니다. | ||
|
|
||
|
|
||
|
|
||
| 규칙의 위치: 특정 규칙이 여러 서비스에서 중복되어 나타난다면, 해당 규칙은 도메인 객체의 책임일 가능성이 높으므로 도메인 내부로 옮깁니다. | ||
|
|
||
|
|
||
| 의도적인 설계: 각 기능의 책임 소재와 객체 간 결합도에 대해 개발자의 의도를 명확히 반영하여 개발을 진행합니다. | ||
|
|
||
| ## 아키텍처 및 패키지 구성 전략 | ||
| 본 프로젝트는 **레이어드 아키텍처(Layered Architecture)**를 기반으로 하며, **DIP(의존성 역전 원칙)**를 jpa 관점에서 적당히 편리한 만큼만 적용한다. | ||
|
|
||
| 패키지 구조 (Layer + Domain) | ||
| 패키징은 4개의 계층을 최상위에 두고, 그 하위에 도메인별로 구성합니다. | ||
|
|
||
|
|
||
|
|
||
| /interfaces/api: Presentation 레이어로 API 컨트롤러와 요청/응답 객체가 위치합니다. | ||
|
|
||
|
|
||
|
|
||
| /application/..: Application 레이어로 도메인 레이어를 조합하여 유스케이스 기능을 제공합니다. | ||
|
|
||
|
|
||
|
|
||
| /domain/..: Domain 레이어로 도메인 객체(Entity, VO, Domain Service)와 Repository 인터페이스가 위치합니다. | ||
|
|
||
|
|
||
|
|
||
| /infrastructure/..: Infrastructure 레이어로 JPA, Redis 등 기술적인 Repository 구현체를 제공합니다. | ||
|
|
||
|
|
||
| 데이터 전달 객체(DTO) 정책 | ||
|
|
||
| DTO 분리: API 계층에서 사용하는 Request/Response DTO와 Application 계층에서 사용하는 DTO를 엄격히 분리하여 작성합니다. | ||
|
|
||
| 의존성 및 테스트 전략 | ||
| DIP 적용: 의존성 방향은 항상 Domain을 향해야 합니다. Infrastructure 구현체는 Domain에 정의된 인터페이스를 상속합니다. | ||
|
|
||
|
|
||
| 단위 테스트: 핵심 도메인 로직은 외부 의존성이 분리된 상태에서 Fake 또는 Stub을 사용하여 테스트 가능한 구조로 설계하고 검증합니다. | ||
| +2 |
42 changes: 42 additions & 0 deletions
42
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| package com.loopers.application.brand; | ||
|
|
||
| import com.loopers.domain.brand.Brand; | ||
| import com.loopers.domain.brand.BrandService; | ||
| import com.loopers.domain.product.ProductService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class BrandFacade { | ||
|
|
||
| private final BrandService brandService; | ||
| private final ProductService productService; | ||
|
|
||
| @Transactional | ||
| public BrandInfo register(String name, String description) { | ||
| Brand brand = brandService.register(name, description); | ||
| return BrandInfo.from(brand); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public BrandInfo getBrand(Long id) { | ||
| return BrandInfo.from(brandService.getBrand(id)); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public List<BrandInfo> getBrands() { | ||
| return brandService.getBrands().stream() | ||
| .map(BrandInfo::from) | ||
| .toList(); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void deleteBrand(Long id) { | ||
| productService.deleteProductsByBrandId(id); | ||
| brandService.deleteBrand(id); | ||
| } | ||
| } |
10 changes: 10 additions & 0 deletions
10
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.loopers.application.brand; | ||
|
|
||
| import com.loopers.domain.brand.Brand; | ||
|
|
||
| public record BrandInfo(Long id, String name, String description) { | ||
|
|
||
| public static BrandInfo from(Brand brand) { | ||
| return new BrandInfo(brand.getId(), brand.getName(), brand.getDescription()); | ||
| } | ||
| } |
58 changes: 58 additions & 0 deletions
58
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| package com.loopers.application.like; | ||
|
|
||
| import com.loopers.application.product.ProductInfo; | ||
| import com.loopers.domain.brand.Brand; | ||
| import com.loopers.domain.brand.BrandService; | ||
| import com.loopers.domain.like.LikeService; | ||
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductService; | ||
| import com.loopers.domain.user.User; | ||
| import com.loopers.domain.user.UserService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class LikeFacade { | ||
|
|
||
| private final LikeService likeService; | ||
| private final ProductService productService; | ||
| private final UserService userService; | ||
| private final BrandService brandService; | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public List<ProductInfo> getLikedProducts(String loginId, String rawPassword) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| List<Long> productIds = likeService.getLikedProductIds(user.getId()); | ||
| // 상품 목록을 IN 쿼리로 한 번에 조회 (N+1 방지) | ||
| List<Product> products = productService.getProductsByIds(productIds); | ||
| // 브랜드도 IN 쿼리로 한 번에 조회 후 Map으로 변환 | ||
| List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList(); | ||
| Map<Long, Brand> brandMap = brandService.getBrandsByIds(brandIds).stream() | ||
| .collect(Collectors.toMap(Brand::getId, b -> b)); | ||
| return products.stream() | ||
| .map(p -> ProductInfo.from(p, brandMap.get(p.getBrandId()))) | ||
| .toList(); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void addLike(String loginId, String rawPassword, Long productId) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| Product product = productService.getProduct(productId); | ||
| likeService.addLike(user.getId(), productId); | ||
| product.increaseLikes(); | ||
| } | ||
|
|
||
| @Transactional | ||
| public void removeLike(String loginId, String rawPassword, Long productId) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| Product product = productService.getProduct(productId); | ||
| likeService.removeLike(user.getId(), productId); | ||
| product.decreaseLikes(); | ||
| } | ||
| } |
126 changes: 126 additions & 0 deletions
126
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| package com.loopers.application.order; | ||
|
|
||
| import com.loopers.domain.brand.Brand; | ||
| import com.loopers.domain.brand.BrandService; | ||
| import com.loopers.support.error.CoreException; | ||
| import com.loopers.support.error.ErrorType; | ||
| import com.loopers.domain.order.Order; | ||
| import com.loopers.domain.order.OrderItem; | ||
| import com.loopers.domain.order.OrderService; | ||
| import com.loopers.domain.product.Product; | ||
| import com.loopers.domain.product.ProductService; | ||
| import com.loopers.domain.user.User; | ||
| import com.loopers.domain.user.UserService; | ||
| 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.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.stream.Collectors; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class OrderFacade { | ||
|
|
||
| private final OrderService orderService; | ||
| private final ProductService productService; | ||
| private final UserService userService; | ||
| private final BrandService brandService; | ||
|
|
||
| @Transactional | ||
| public OrderInfo createOrder(String loginId, String rawPassword, | ||
| List<OrderRequest.OrderItemRequest> items) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
|
|
||
| List<Product> products = new ArrayList<>(); | ||
| long totalAmount = 0L; | ||
| for (OrderRequest.OrderItemRequest item : items) { | ||
| Product product = productService.getProduct(item.productId()); | ||
| product.decreaseStock(item.quantity()); | ||
| totalAmount += (long) product.getPrice() * item.quantity(); | ||
| products.add(product); | ||
| } | ||
|
|
||
| user.deductBalance(totalAmount); | ||
|
|
||
| // 주문 시점의 브랜드명을 스냅샷으로 저장하기 위해 브랜드 정보를 한 번에 조회 (N+1 방지) | ||
| List<Long> brandIds = products.stream().map(Product::getBrandId).distinct().toList(); | ||
| Map<Long, Brand> brandMap = brandService.getBrandsByIds(brandIds).stream() | ||
| .collect(Collectors.toMap(Brand::getId, b -> b)); | ||
|
|
||
| String orderNumber = orderService.generateOrderNumber(); | ||
| Order order = orderService.createOrder(user.getId(), orderNumber, totalAmount); | ||
|
|
||
| // 상품명, 브랜드명, 이미지 URL, 단가를 스냅샷으로 저장 (이후 상품 정보 변경에도 주문 내역 보존) | ||
| for (int i = 0; i < items.size(); i++) { | ||
| Product product = products.get(i); | ||
| OrderRequest.OrderItemRequest item = items.get(i); | ||
| Brand brand = brandMap.get(product.getBrandId()); | ||
| orderService.createOrderItem(order.getId(), product.getId(), | ||
| product.getName(), brand.getName(), product.getImageUrl(), product.getPrice(), item.quantity()); | ||
| } | ||
|
|
||
| List<OrderItem> orderItems = orderService.getOrderItems(order.getId()); | ||
| return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList()); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public Page<OrderInfo> getOrders(String loginId, String rawPassword, Pageable pageable) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| Page<Order> orders = orderService.getOrders(user.getId(), pageable); | ||
|
|
||
| List<Long> orderIds = orders.stream().map(Order::getId).toList(); | ||
| Map<Long, List<OrderItemInfo>> itemsByOrderId = orderService.getOrderItemsByOrderIds(orderIds).stream() | ||
| .collect(Collectors.groupingBy( | ||
| OrderItem::getOrderId, | ||
| Collectors.mapping(OrderItemInfo::from, Collectors.toList()) | ||
| )); | ||
|
|
||
| return orders.map(order -> | ||
| OrderInfo.from(order, itemsByOrderId.getOrDefault(order.getId(), List.of())) | ||
| ); | ||
| } | ||
|
|
||
| @Transactional(readOnly = true) | ||
| public OrderInfo getOrderDetail(String loginId, String rawPassword, Long orderId) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| Order order = orderService.getOrder(orderId); | ||
| if (!order.getUserId().equals(user.getId())) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); | ||
| } | ||
| List<OrderItem> items = orderService.getOrderItems(orderId); | ||
| return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); | ||
| } | ||
|
|
||
| @Transactional | ||
| public OrderInfo cancelOrder(String loginId, String rawPassword, Long orderId) { | ||
| User user = userService.authenticate(loginId, rawPassword); | ||
| Order found = orderService.getOrder(orderId); | ||
| if (!found.getUserId().equals(user.getId())) { | ||
| throw new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."); | ||
| } | ||
| Order order = orderService.cancelOrder(found); | ||
|
|
||
| List<OrderItem> orderItems = orderService.getOrderItems(orderId); | ||
| for (OrderItem item : orderItems) { | ||
| Product product = productService.getProduct(item.getProductId()); | ||
| product.increaseStock(item.getQuantity()); | ||
| } | ||
|
|
||
| user.restoreBalance(order.getTotalAmount()); | ||
|
|
||
| return OrderInfo.from(order, orderItems.stream().map(OrderItemInfo::from).toList()); | ||
| } | ||
|
|
||
| @Transactional | ||
| public OrderInfo approveOrder(String loginId, String rawPassword, Long orderId) { | ||
| userService.authenticate(loginId, rawPassword); | ||
| Order order = orderService.approveOrder(orderId); | ||
| List<OrderItem> items = orderService.getOrderItems(orderId); | ||
| return OrderInfo.from(order, items.stream().map(OrderItemInfo::from).toList()); | ||
| } | ||
| } | ||
26 changes: 26 additions & 0 deletions
26
apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| package com.loopers.application.order; | ||
|
|
||
| import com.loopers.domain.order.Order; | ||
| import com.loopers.domain.order.OrderStatus; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record OrderInfo( | ||
| Long id, | ||
| Long userId, | ||
| String orderNumber, | ||
| OrderStatus status, | ||
| Long totalAmount, | ||
| List<OrderItemInfo> items | ||
| ) { | ||
| public static OrderInfo from(Order order, List<OrderItemInfo> items) { | ||
| return new OrderInfo( | ||
| order.getId(), | ||
| order.getUserId(), | ||
| order.getOrderNumber(), | ||
| order.getStatus(), | ||
| order.getTotalAmount(), | ||
| items | ||
| ); | ||
| } | ||
| } |
25 changes: 25 additions & 0 deletions
25
apps/commerce-api/src/main/java/com/loopers/application/order/OrderItemInfo.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.loopers.application.order; | ||
|
|
||
| import com.loopers.domain.order.OrderItem; | ||
|
|
||
| public record OrderItemInfo( | ||
| Long id, | ||
| Long productId, | ||
| String productName, | ||
| String brandName, | ||
| String imageUrl, | ||
| Integer price, | ||
| Integer quantity | ||
| ) { | ||
| public static OrderItemInfo from(OrderItem item) { | ||
| return new OrderItemInfo( | ||
| item.getId(), | ||
| item.getProductId(), | ||
| item.getProductName(), | ||
| item.getBrandName(), | ||
| item.getImageUrl(), | ||
| item.getPrice(), | ||
| item.getQuantity() | ||
| ); | ||
| } | ||
| } |
12 changes: 12 additions & 0 deletions
12
apps/commerce-api/src/main/java/com/loopers/application/order/OrderRequest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package com.loopers.application.order; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record OrderRequest( | ||
| List<OrderItemRequest> items | ||
| ) { | ||
| public record OrderItemRequest( | ||
| Long productId, | ||
| Integer quantity | ||
| ) {} | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.