Skip to content

[volume-3] 도메인 & 객체 설계 및 아키텍처, 패키지 구성#91

Open
YoHanKi wants to merge 50 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:feat/week3-domain-modeling
Open

[volume-3] 도메인 & 객체 설계 및 아키텍처, 패키지 구성#91
YoHanKi wants to merge 50 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:feat/week3-domain-modeling

Conversation

@YoHanKi
Copy link

@YoHanKi YoHanKi commented Feb 25, 2026

📌 Summary

  • 배경: 레이어드 아키텍처의 의존성 방향 규칙(Interfaces → Application → Domain ← Infrastructure)이 코드 수준에서 강제되지 않았다. Repository 인터페이스가 Domain에 있음에도 Application이 직접 Infrastructure를 참조하거나, Facade가 단일 도메인에 과하게 사용되는 등 DIP가 일관되게 적용되지 않았다.
  • 목표: Brand/Product/Like/Order 4개 도메인을 Entity, VO, Domain Service로 모델링하고, 레이어드 아키텍처 + DIP를 전 레이어에 일관되게 적용한다. Application Layer는 도메인 객체를 조합하는 경량 오케스트레이터로 구성하며, 도메인 로직의 정합성을 단위 테스트로 검증한다.
  • 결과: 4개 도메인 14개 API 구현 완료. Domain Repository 인터페이스(Port)-Infrastructure 구현체(Adapter) 분리를 전 도메인에 적용. App/Facade 역할 확정(App=단일 도메인 유스케이스, Facade=복수 App 조합). 트랜잭션 경계 App 레이어로 명시. ArchUnit으로 의존성 방향 자동 검증. 단위/통합/E2E 테스트 37개 추가.

🧭 Context & Decision

1. Application은 반드시 Service를 경유해야 하는가

문제 정의

  • 현재 동작: 초기 설계에서 App → Service → Repository가 모든 유스케이스에 강제되었다.
  • 문제: Service가 비즈니스 로직 없이 단순히 repository.findBy...()를 위임만 하는 메서드들이 생겨났다. 이는 불필요한 간접 레이어를 만들고, 코드 추적 비용을 높인다.
  • 성공 기준: 비즈니스 규칙이 없는 단순 조회는 App에서 직접 Repository를 호출하고, 그 규칙이 코드 컨벤션으로 명문화된다.

선택지와 결정

  • A: 모든 유스케이스에서 Service를 경유 (단순 조회도 Service 메서드로 래핑)
  • B: 비즈니스 로직(검증·상태변경·크로스도메인)이 있을 때만 Service 경유, 단순 조회는 App → Repository 직접 호출 허용

최종 결정: B 선택

  • 트레이드오프: Service가 단순 위임 메서드로 비대해지는 것을 방지하고 코드 응집도를 높이지만, "언제 Service를 써야 하는가"라는 판단 기준을 개발자가 명확히 숙지해야 한다.
  • 코드 예시:
    // 단순 조회 → App에서 Repository 직접 호출
    public BrandInfo getBrand(String brandId) {
        BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId))
                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "..."));
        return BrandInfo.from(brand);
    }
    
    // 비즈니스 로직 포함 → Service 경유
    public BrandInfo createBrand(String brandId, String brandName) {
        BrandModel brand = brandService.createBrand(brandId, brandName); // 중복 체크 포함
        return BrandInfo.from(brand);
    }

2. App vs Facade — Controller는 무엇을 의존해야 하는가

문제 정의

  • 현재 동작: 초기에는 단일 도메인 유스케이스도 Facade로 묶는 경우가 있었고, Facade가 Repository를 직접 참조하거나 Service를 직접 호출하는 패턴이 혼재했다.
  • 문제: Facade가 조합 없이 단일 App을 래핑만 할 경우 Facade의 의미가 퇴색된다. Facade → Repository 직접 의존은 레이어 경계를 무너뜨린다.
  • 성공 기준: App은 단일 도메인 유스케이스를, Facade는 2개 이상 App 조합만 담당하는 구조가 코드와 ArchUnit 테스트로 강제된다.

선택지와 결정

  • A: Controller → Facade → App → Service/Repository (모든 경우에 Facade 경유)
  • B: 단일 도메인은 Controller → App, 크로스 도메인은 Controller → Facade → App(복수)

최종 결정: B 선택

  • 트레이드오프: Controller가 App과 Facade를 모두 의존할 수 있어 일관성이 낮아 보이지만, 각 컴포넌트의 책임이 명확히 분리되고 Facade가 실질적인 오케스트레이션 역할을 수행한다.

  • 추후 개선 여지: ArchUnit 규칙으로 Facade가 반드시 2개 이상의 App을 의존해야 함을 자동 검증하도록 확장 가능하다.

  • 적용 예시:

    Facade 조합하는 App 이유
    BrandFacade BrandApp + ProductApp Brand 삭제 → Product 연쇄 soft delete
    ProductFacade ProductApp + BrandApp 상품 응답에 브랜드명/좋아요 수 보강
    LikeFacade LikeApp + ProductApp + BrandApp 좋아요 목록에 상품/브랜드 정보 보강

3. JPA Entity를 Infrastructure에서 분리하지 않은 이유

문제 정의

  • 현재 동작: BrandModel, ProductModel 등 Entity 클래스에 @Entity, @Table, @Convert 등 JPA 어노테이션이 직접 붙어있고, 이 클래스들은 Domain 레이어에 위치한다.
  • 고민: 순수한 Domain 레이어라면 JPA 기술(@Entity, @Table)을 모르는 것이 이상적이다. Domain Entity와 JPA Entity를 별도 클래스로 분리하면 Domain이 기술 의존을 갖지 않는다.

선택지와 결정

  • A: Domain Entity와 JPA Entity를 별도 클래스로 분리 (Pure Domain Model + JpaEntity in Infrastructure)
  • B: @Entity + @Table 어노테이션을 Domain Model에 직접 적용하고 Domain 레이어에 배치

최종 결정: B 선택

  • 이유:
    • JPA 핵심 기능 활용: A 방식으로 분리하면 JPA의 더티 체킹(Dirty Checking)과 1차 캐시(First-Level Cache)를 사용할 수 없다. Entity가 영속성 컨텍스트에 관리되지 않으면 상태 변경마다 명시적 save()가 필요하고, 1차 캐시의 동일성 보장도 깨진다.
    • 의존 방향 역전 문제: Entity를 Infrastructure에 두면 Domain Service가 Infrastructure의 JpaEntity를 import해야 하거나, Domain Repository 인터페이스가 Infrastructure 타입을 반환 타입으로 가져야 하므로 의존 방향이 역전된다.
    • 경계 모호성: Domain Entity와 JpaEntity가 별도로 존재하면 상태 동기화 로직이 생기고, 어디까지가 도메인 행위이고 어디까지가 ORM 매핑인지 경계가 모호해진다.
    • 구현 편의성 우선: 이 프로젝트의 현재 단계에서는 JPA 기능을 충분히 활용하는 것이 더 중요하다고 판단했다.
  • 트레이드오프: Domain 레이어가 JPA 기술을 완전히 모르는 상태는 포기하지만, 더티 체킹/1차 캐시 활용, 코드 단순성, 명확한 의존 방향을 얻는다.

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인:

    • apps/commerce-api — application, domain, infrastructure, interfaces 전 레이어
    • 신규 도메인: brand, product, like, order
  • 신규 추가:

    • Domain: BrandModel, ProductModel, LikeModel, OrderModel, OrderItemModel + 각 VO/Repository/Service
    • Application: BrandApp/Facade, ProductApp/Facade, LikeApp/Facade, OrderApp, MemberApp
    • Infrastructure: *JpaRepository, *RepositoryImpl, *Converter (각 도메인)
    • Interfaces: BrandV1Controller, ProductV1Controller, ProductAdminV1Controller, LikeV1Controller, MyLikeV1Controller, OrderV1Controller
  • 제거/대체:

    • *Facade (단일 도메인) → *App으로 대체: MemberFacade 제거, OrderFacade 제거
    • Reader 추상화 레이어 제거 → Repository로 통합
    • EntityManager 직접 사용 제거 → JpaRepository @Query로 대체
    • Service의 단순 조회 메서드 제거 → App에서 Repository 직접 호출
    • @Transactional Service 레이어 제거 → App 레이어로 이관
    • 도메인별 Ref*Id VO 중복 → domain.common.vo로 통합

주요 컴포넌트 책임

  • BrandApp: 브랜드 생성(Service 경유), 단건 조회(Repository 직접), 삭제(Service 경유)
  • BrandFacade: 브랜드 삭제 시 상품 연쇄 soft delete 오케스트레이션 (BrandApp + ProductApp)
  • ProductApp: 상품 CRUD. 조회는 Repository 직접, 생성/수정/삭제는 Service 경유
  • ProductFacade: 상품 응답에 브랜드명/좋아요 수 보강 (ProductApp + BrandApp)
  • LikeApp: 좋아요 추가/취소(Service 경유), 목록 조회(Repository 직접)
  • LikeFacade: 좋아요 목록에 상품명/브랜드명/가격 보강 (LikeApp + ProductApp + BrandApp)
  • OrderApp: 주문 생성/취소(Service 경유), 상세/목록 조회 혼합
  • OrderService: 재고 차감/복구, 데드락 방지(상품 ID 정렬), 소유권 검증 — 크로스 도메인 Repository 의존
  • LikeService: 중복 방지(DB 유니크 + DataIntegrityViolationException 방어), 크로스 도메인 Repository 의존
  • *RepositoryImpl: Domain Repository 인터페이스 구현 (Port-Adapter), JpaRepository 위임

🔁 Flow Diagram

(untitled) (1)

Interfaces Layer

HTTP 요청을 수신하고 응답을 반환하는 표현 계층. 웹 기술(HTTP 상태코드, 헤더, 직렬화)에 대한 처리를 이 레이어에서 처리합니다. Application 계층이 반드시 HTTP를 통해 호출된다는 보장이 없으므로, 웹 관련 처리는 이 레이어에서 모두 끝내고 순수한 파라미터만 전달합니다.

Application Layer

단일 도메인 유스케이스 처리(App)와 복수 도메인 조합(Facade)을 담당하는 경량 오케스트레이션 계층. 비즈니스 규칙은 Domain에 위임하고, 트랜잭션 경계 설정과 Model → Info 변환만 담당합니다.

구성 요소 책임
*App 단일 도메인 유스케이스 처리. 비즈니스 로직이 있으면 Service 경유, 단순 조회는 Repository 직접 호출. Model → Info 변환 담당
*Facade 2개 이상의 App을 조합할 때만 생성. App만 의존하며 Service·Repository를 직접 호출하지 않음

Facade 적용 기준: BrandFacade(Brand 삭제 + Product 연쇄 삭제), ProductFacade(상품 + 브랜드명/좋아요 수 보강), LikeFacade(좋아요 목록 + 상품/브랜드 정보 조합). 단일 도메인 유스케이스는 Facade 없이 App을 직접 사용.

Domain Layer

비즈니스 핵심 정책과 규칙을 담는 계층. Spring·JPA·HTTP 등 기술을 직접 사용하지 않는 것이 이상적이나, JPA의 더티 체킹·1차 캐시 등 ORM 기능을 온전히 활용하기 위해 @Entity·@Table 어노테이션은 Model에 직접 적용합니다. Repository는 인터페이스(Port)만 정의하여 Infrastructure와의 의존을 역전시킵니다.

Infrastructure Layer

Domain Repository 인터페이스를 구현하는 기술 구현 계층(Adapter). JPA 등 구체 기술이 이 레이어에만 집중됩니다.

JPA Entity를 Domain에 둔 이유: Entity를 Infrastructure로 분리하면 JPA의 더티 체킹(트랜잭션 내 상태 변경 자동 반영)과 1차 캐시(동일 트랜잭션 내 동일성 보장)를 사용할 수 없습니다. 따라서 @Entity 어노테이션은 Model에 직접 적용하고, VO ↔ DB 변환만 Infrastructure의 Converter가 담당하는 방식으로 타협합니다.

- BrandService 구현 (createBrand, deleteBrand)
- BrandReader 구현 (getOrThrow, exists 패턴)
- BrandRepository 인터페이스 정의 (Port)
- BrandRepositoryImpl 구현 (Adapter, Port-Adapter 패턴)
- BrandJpaRepository 구현 (Spring Data JPA)
- 단위 테스트 작성 (Repository/Reader 모킹)
- 통합 테스트 작성 (Spring Context, DatabaseCleanUp)
- BrandFacade 구현 (Service 조율, @transactional 경계)
- BrandInfo 구현 (record, from(BrandModel) 팩토리)
- BrandV1Controller 구현 (POST, DELETE 엔드포인트)
- BrandV1Dto 구현 (CreateBrandRequest, BrandResponse)
- BrandV1ApiSpec 구현 (OpenAPI 명세)
- E2E 테스트 작성 (브랜드 생성→삭제 플로우)
- .http/brand.http 작성 (수동 테스트용)
- ProductId VO 구현 (영문+숫자, 1-20자 검증)
- ProductName VO 구현 (1-100자 검증)
- Price VO 구현 (BigDecimal, 음수 불가, scale 2)
- StockQuantity VO 구현 (int, 음수 불가)
- ProductModel Entity 구현 (BaseEntity 상속, BrandId FK, soft delete 지원)
- 재고 관리 메서드 구현 (decreaseStock, increaseStock)
- JPA Converter 구현 (ProductId, ProductName, Price, StockQuantity)
- 단위 테스트 작성 (VO 검증 규칙, Entity 도메인 메서드)
- ProductFacade 추가 (Application Layer)
- ProductInfo 추가 (Entity → DTO 변환)
- ProductV1Controller 추가 (REST API 엔드포인트)
- POST /api/v1/products (상품 생성)
- GET /api/v1/products (목록 조회, 페이징/필터링/정렬)
- DELETE /api/v1/products/{productId} (상품 삭제)
- ProductV1Dto 추가 (Request/Response DTOs)
- Jakarta Validation 적용
- ProductV1ApiSpec 추가 (OpenAPI 명세)
- E2E 테스트 작성 (8개 테스트, @nested 구조)
- .http/product.http 추가 (수동 테스트용)
- ArchUnit 테스트 통과 (레이어 아키텍처 검증)
Copilot AI review requested due to automatic review settings February 25, 2026 13:08
@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Tip

Try Coding Plans. Let us write the prompt for your AI agent so you can ship faster (with fewer bugs).
Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@YoHanKi YoHanKi changed the title [volume-2] 도메인 & 객체 설계 및 아키텍처, 패키지 구성 [volume-3] 도메인 & 객체 설계 및 아키텍처, 패키지 구성 Feb 25, 2026
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR implements a comprehensive architectural refactoring introducing layered architecture with DIP (Dependency Inversion Principle) across 4 domains: Brand, Product, Like, and Order. The PR successfully delivers 14 APIs with 37 tests (unit/integration/E2E) and enforces architectural rules using ArchUnit.

Changes:

  • Introduced layered architecture: Interfaces → Application → Domain ← Infrastructure
  • Separated App (single domain use cases) from Facade (cross-domain orchestration)
  • Implemented Domain Repository interfaces as Ports with Infrastructure Adapters
  • Moved transaction boundaries to Application layer
  • Added comprehensive test coverage with ArchUnit validation

Reviewed changes

Copilot reviewed 164 out of 165 changed files in this pull request and generated no comments.

Show a summary per file
File Description
settings.gradle.kts Added security module
modules/security/* Extracted security (PasswordHasher) to separate module
domain//vo/ Value Objects with validation for Brand, Product, Order domains
domain//.java Domain Models, Services, and Repository interfaces
application//.java App/Facade components with transaction management
infrastructure//.java Repository implementations and JPA converters
interfaces/api//.java Controllers, DTOs, and API specs
architecture/*.java ArchUnit tests enforcing layered architecture rules
test//.java 37 unit/integration/E2E tests

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants