패키지 구조 표준infra를 전부 package-private으로 막아서 “Entity는 패키지 밖으로 나가지 않는다”를 약속이 아니라 사실로 만들었다. domain에 인터페이스를 두고 구현체를 infra에 감춘 덕분이다.

그런데 그 위 계층인 application에는 같은 장치가 없다.

// order/application/OrderService.java — public 클래스다
@Service
public class OrderService {
    public ServiceResult<OrderDomain> createOrder(CreateOrderCommand command) { ... }
    public ServiceResult<OrderDomain> cancelOrder(CancelOrderCommand command) { ... }
    public PagingResult<OrderDomain> searchOrders(OrderSearchQuery query) { ... }
    public void recalculateTotals(Long orderId) { ... }      // 내부 정산용
    public OrderDomain loadForOtherDomain(Long id) { ... }    // 다른 도메인이 쓰는 것
}
// order/api/OrderAdminController.java
private final OrderService orderService;   // 다섯 개 전부 호출할 수 있다

Controller가 검색 하나만 필요한데 다섯 개를 다 쥔다. recalculateTotals()를 컨트롤러에서 부르는 코드가 들어와도 컴파일된다. 지금까지 이 저장소가 계속 밀어온 “규칙을 컴파일러가 강제하게”에서 이 계층만 빠져 있는 셈이다.

그리고 위 코드를 다시 보면 문제가 하나 더 겹쳐 있다. package-structure 표준은 “다른 도메인은 상대 도메인의 application이 공개한 것만 쓴다”고 정했다. 즉 OrderService의 public 메서드는 두 종류의 호출자를 동시에 상대한다 — 자기 도메인의 Controller, 그리고 다른 도메인의 Service. 하나의 표면에 성격이 다른 둘이 섞여 있다.

이 글은 그 표면을 나누고 닫는다.


먼저: 이미 정해진 것

  • application에는 Service와 XxxCommand, XxxQuery를 둔다 — 패키지 구조 표준
  • api, application, domain의 클래스는 public으로 만든다 — 같은 곳
  • 다른 도메인의 domain이나 infra는 직접 import하지 않는다 — 같은 곳
  • Service 출력은 ServiceResult<Domain> 계열로 감싼다 — DTO 네이밍 표준

두 번째와 네 번째를 이 글에서 고친다. application의 클래스가 전부 public이어야 할 이유는 없었고, 네 번째는 뒤에서 보듯 세 번째와 정면으로 부딪힌다.


원칙 1. Controller는 UseCase 인터페이스만 주입받는다

applicationpublic 인터페이스를 두고 Controller는 그것만 안다.

// order/application/OrderCommandUseCase.java
public interface OrderCommandUseCase {
    ServiceResult<OrderDomain> createOrder(CreateOrderCommand command);
    ServiceResult<OrderDomain> cancelOrder(CancelOrderCommand command);
}

// order/application/OrderQueryUseCase.java
public interface OrderQueryUseCase {
    ServiceResult<OrderDomain> findOrder(FindOrderQuery query);
    PagingResult<OrderDomain> searchOrders(OrderSearchQuery query);
}
// order/api/OrderController.java
@RestController
@RequiredArgsConstructor
public class OrderController {

    private final OrderCommandUseCase orderCommandUseCase;   // 쓰기만
    private final OrderQueryUseCase orderQueryUseCase;       // 읽기만

    @PostMapping("/orders")
    public HttpApiResponse<OrderResponse> createOrder(@Valid @RequestBody CreateOrderRequest request) {
        ServiceResult<OrderDomain> result = orderCommandUseCase.createOrder(request.toCommand());
        return HttpApiResponse.success(OrderResponse.from(result));
    }
}

분할 단위는 기능 묶음이다

인터페이스를 어느 굵기로 쪼갤지가 실제로 고민된 지점이다. 세 가지를 놓고 비교했다.

단위 결과 문제
엔드포인트 1:1 CreateOrderUseCase, CancelOrderUseCase, … 엔드포인트 수만큼 파일이 늘고 인터페이스마다 메서드가 하나다
Controller 1:1 OrderUseCase, OrderAdminUseCase api의 관심사가 application의 구조를 정한다. Controller가 갈리면 인터페이스도 갈린다
기능 묶음 OrderCommandUseCase, OrderQueryUseCase 도메인당 두세 개 수준

기능 묶음으로 정했다. 결정적인 이유는 중간이라서가 아니라, 이미 Command/Query로 입력 타입을 갈라놨기 때문이다. 쓰기와 읽기를 다른 타입으로 받기로 한 표준이 있는데 인터페이스를 그 선에 맞추면 새로 외울 기준이 없다.

Controller 1:1을 버린 이유는 방향이 거꾸로이기 때문이다. api → application 의존이므로 applicationapi가 몇 개의 Controller로 나뉘는지 몰라야 한다. Controller를 하나 추가할 때 application의 인터페이스 구성이 따라 바뀐다면 그건 의존 방향이 새는 신호다.


원칙 2. Service 구현체는 package-private이다

원칙 1만으로는 아무것도 강제되지 않는다. OrderServicepublic인 채로 남으면 Controller가 그냥 그걸 주입받아도 컴파일된다. 인터페이스를 만든 보람이 사라지고 규칙이 다시 사람의 약속으로 돌아간다.

그래서 public을 뗀다.

// order/application/OrderService.java
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase, OrderQueryUseCase {
//^^^^^ public이 아니다

    private final OrderRepository orderRepository;
    private final OrderSearchRepository orderSearchRepository;

    @Override
    public PagingResult<OrderDomain> searchOrders(OrderSearchQuery query) {
        return orderSearchRepository.search(query.toCondition());
    }

    // UseCase에 없는 메서드 — api에서 존재 자체를 알 수 없다
    void recalculateTotals(Long orderId) { ... }
}
// order/api/OrderController.java
import com.example.shop.order.application.OrderService;         // ❌ 컴파일 에러
import com.example.shop.order.application.OrderQueryUseCase;    // ✅

주입이 인터페이스 타입으로 이루어지므로 구현체를 감춰도 동작한다. 이건 이미 검증된 방식이다infra의 Repository 구현체를 package-private으로 두고 domain의 인터페이스로 주입받는 것과 완전히 같다.

구조가 대칭이 된다

graph TD API["api
Controller"] --> UC["XxxUseCase
public 인터페이스"] UC -.->|implements| SVC["XxxService
package-private"] SVC --> REPO["XxxRepository
public 인터페이스"] REPO -.->|implements| IMPL["구현체
package-private"] style API fill:#2d3748,stroke:#4299e1,stroke-width:2px,color:#e2e8f0 style UC fill:#1a202c,stroke:#48bb78,stroke-width:3px,color:#e2e8f0 style SVC fill:#2d3748,stroke:#48bb78,stroke-width:2px,color:#e2e8f0 style REPO fill:#1a202c,stroke:#ed8936,stroke-width:3px,color:#e2e8f0 style IMPL fill:#2d3748,stroke:#f56565,stroke-width:2px,color:#e2e8f0
계층 공개 계약 감춘 구현
application XxxUseCase (public) XxxService (package-private)
domain XxxRepository (public) infra의 구현체 (package-private)

“인터페이스는 공개, 구현은 감춤”이 두 계층에서 같은 모양으로 반복된다. 규칙이 하나라 외울 것이 늘지 않는다.


원칙 3. 다른 도메인에는 Domain을 넘기지 않는다

여기서 기존 표준의 모순이 드러났다. 도메인 간 호출을 실제 코드로 써 보면 알 수 있다.

결제 도메인이 결제를 만들면서 주문 금액을 확인해야 한다고 하자.

// payment/application/PaymentService.java
ServiceResult<OrderDomain> result = orderQueryUseCase.findOrder(FindOrderQuery.of(orderId));
OrderDomain order = result.getData();
BigDecimal amount = order.getTotalPrice();

이 코드를 컴파일하려면 이런 import가 필요하다.

import com.example.shop.order.application.OrderQueryUseCase;   // OK — 상대의 application
import com.example.shop.order.domain.OrderDomain;              // ❌ 상대의 domain

두 규칙이 부딪힌다.

규칙 출처
Service 출력은 ServiceResult<Domain>이다 DTO 네이밍 표준
다른 도메인의 domain은 import하지 않는다 패키지 구조 표준

Service의 반환 타입이 Domain을 품고 있는 한, 다른 도메인은 그 Service를 호출할 수 없다. 패키지 구조 글이 ProductQueryService를 “상품 도메인의 공개 창구”라고 그려놓고도 그것이 무엇을 반환하는지 적지 않았던 이유가 이거였다. 적으려 하면 바로 막힌다.

규칙 위반이 아니라, 막아야 하는 것이 막힌 것이다

OrderDomain을 그대로 넘기면 이게 가능해진다.

// payment/application/PaymentService.java
OrderDomain order = orderQueryUseCase.findOrder(query).getData();

OrderDomain cancelled = order.cancel();     // 결제가 주문의 상태 전이를 호출
order.validateCancelable();                 // 결제가 주문의 규칙을 판단

주문의 비즈니스 로직이 결제 안에서 실행된다. 주문 규칙이 두 곳에 흩어지고, 주문 쪽이 cancel()의 동작을 바꾸면 결제가 조용히 깨진다.

그래서 창구와 전용 타입을 둔다

// order/application/OrderSnapshot.java — 다른 도메인이 받는 타입
@Getter
public class OrderSnapshot {

    private final Long orderId;
    private final Long userId;
    private final BigDecimal totalPrice;
    private final OrderStatus status;

    private OrderSnapshot(Long orderId, Long userId, BigDecimal totalPrice, OrderStatus status) {
        this.orderId = orderId;
        this.userId = userId;
        this.totalPrice = totalPrice;
        this.status = status;
    }

    static OrderSnapshot from(OrderDomain order) {      // package-private 팩토리
        return new OrderSnapshot(
                order.getId(), order.getUserId(), order.getTotalPrice(), order.getStatus());
    }
}
// order/application/OrderProvider.java — 도메인 간 계약
public interface OrderProvider {
    OrderSnapshot getOrder(Long orderId);
}
// order/application/OrderService.java — 셋 다 구현한다
@Service
@RequiredArgsConstructor
class OrderService implements OrderCommandUseCase, OrderQueryUseCase, OrderProvider {

    @Override
    public OrderSnapshot getOrder(Long orderId) {
        OrderDomain order = orderRepository.findById(orderId)
                .orElseThrow(() -> BusinessException.of(ErrorCode.ORDER_NOT_FOUND));
        return OrderSnapshot.from(order);       // Domain을 밖으로 내보내지 않는다
    }
}
// payment/application/PaymentService.java
import com.example.shop.order.application.OrderProvider;    // OK
import com.example.shop.order.application.OrderSnapshot;    // OK — application 소속

@Service
@RequiredArgsConstructor
class PaymentService implements PaymentCommandUseCase {

    private final OrderProvider orderProvider;

    @Override
    @Transactional
    public ServiceResult<PaymentDomain> pay(PayCommand command) {
        OrderSnapshot order = orderProvider.getOrder(command.getOrderId());

        if (!order.getTotalPrice().equals(command.getAmount())) {
            throw BusinessException.of(ErrorCode.PAYMENT_AMOUNT_MISMATCH);
        }

        PaymentDomain payment = PaymentDomain.create(
                order.getOrderId(), order.getUserId(), command.getAmount());
        return ServiceResult.of(paymentRepository.save(payment));
    }
}

from()을 package-private으로 둔 것도 의도적이다. order/application 안에서만 Snapshot을 만들 수 있다. 다른 도메인이 임의로 조립해서 진짜 주문 데이터인 척하는 길을 막는다.

이건 새 패턴이 아니다

같은 구조가 이미 표준에 세 번 나온다.

경계 안쪽 타입 나가는 타입 변환하는 곳
infradomain OrderEntity OrderDomain OrderEntityMapper
applicationapi OrderDomain OrderResponse OrderResponse.from()
주문 → 다른 도메인 OrderDomain OrderSnapshot OrderSnapshot.from()

경계를 넘을 때 타입이 바뀐다는 규칙의 네 번째 사례다.

Provider는 봉투로 감싸지 않는다

OrderProvider.getOrder()ServiceResult<OrderSnapshot>이 아니라 OrderSnapshot을 그대로 반환한다. ServiceResult 계열은 api 경계로 나가는 응답을 위한 봉투이고, 도메인 간 호출은 그 경계가 아니다. 없어도 되는 껍데기를 씌우지 않는다.

왜 제공하는 도메인이 소유하나

인터페이스를 호출하는 쪽이 선언하는 방법도 있다. 결제가 OrderAmountReader를 정의하고 주문이 그것을 구현하는 방식이다. 이건 버렸다.

제공 도메인이 소유:              호출 도메인이 소유:
  order ← payment                  order → payment
  order ← delivery                  order → delivery
  order ← settlement                order → settlement

  주문은 소비자를 모른다            주문이 세 도메인을 전부 import한다
  인터페이스 하나                   주문에 어댑터 세 개

의존이 한 방향이어야 주문 도메인만 떼어낼 수 있다. 호출하는 쪽이 선언하면 소비자가 늘어날 때마다 주문이 상대 패키지를 import하게 되고, 결국 모든 도메인이 서로를 알게 된다.

대가도 있다. OrderProvider에 메서드가 늘어나는 건 곧 소비자가 생겼다는 뜻이라, 주문이 “누가 나를 쓰는지”를 간접적으로 알게 된다. 그래도 소비자 목록이 인터페이스 하나에 모여 보이는 편이 낫다 — 안 그러면 누가 주문을 어떻게 쓰는지 알려고 코드 전체를 grep해야 한다.


원칙 4. Snapshot은 하나로 시작하고, 나눌 때는 목적으로 나눈다

소비자가 늘면 Snapshot이 비대해진다.

// 소비자가 셋이 되면
public class OrderSnapshot {
    private final Long orderId;
    private final Long userId;
    private final BigDecimal totalPrice;      // 결제만 쓴다
    private final OrderStatus status;         // 결제만 쓴다
    private final String receiverName;        // 배송만 쓴다
    private final String address;             // 배송만 쓴다
    private final String zipCode;             // 배송만 쓴다
    private final BigDecimal supplyPrice;     // 정산만 쓴다
    private final BigDecimal vat;             // 정산만 쓴다
    private final Long vendorId;              // 정산만 쓴다
}

결제는 아홉 개 중 넷만 쓰는데 아홉 개를 다 받는다. 조회할 때 필요 없는 값까지 채워야 하고, 정산 때문에 필드가 추가되면 결제 쪽도 다시 컴파일된다. Domain을 그대로 넘기지 않기로 한 이유가 여기서 그대로 반복된다.

소비자 이름으로 나누면 안 된다

// ❌ 주문 도메인의 타입 이름에 다른 도메인 이름이 박힌다
OrderPaymentSnapshot
OrderDeliverySnapshot
OrderSettlementSnapshot

이러면 주문이 소비자를 아는 구조가 되고, 결제 도메인이 사라지면 주문 쪽 타입도 지워야 한다. 원칙 3에서 “주문은 소비자를 모른다”고 정한 것과 어긋난다.

// ✅ 무엇을 담는지로 나눈다
OrderAmountSnapshot     // orderId, userId, totalPrice, status
OrderShippingSnapshot   // orderId, receiverName, address, zipCode
OrderRevenueSnapshot    // orderId, supplyPrice, vat, vendorId

이러면 주문은 여전히 소비자를 모른다. 결제가 OrderAmountSnapshot을 쓰든 다른 무엇이 쓰든 상관하지 않는다.

나눌 시점의 기준

처음부터 셋으로 쪼개 두는 건 낭비다. 소비자가 하나일 때는 OrderSnapshot 하나로 시작한다.

소비자마다 쓰는 필드가 갈라지고, 안 쓰는 필드가 절반을 넘으면 목적별로 나눈다.

패키지 구조 표준이 Projection에 대해 “단건이나 소량은 조합해도 되고, 쿼리가 반복되기 시작하면 Projection으로 옮긴다”고 정한 것과 같은 방식이다. 미리 나누지 않고, 나눌 신호를 정해둔다.


판단 기준 정리

질문 결론
Controller가 무엇을 주입받나? XxxCommandUseCase / XxxQueryUseCase
UseCase를 어느 굵기로 나누나? 기능 묶음 쓰기·읽기 단위. Controller 수에 맞추지 않는다
XxxService의 가시성은? package-private. UseCase 인터페이스만 public
다른 도메인이 호출하는 창구는? XxxProvider (public 인터페이스)
다른 도메인에 무엇을 반환하나? XxxSnapshot. DomainServiceResult도 아니다
Snapshot 팩토리의 가시성은? package-private. 제공 도메인만 만들 수 있다
Snapshot을 언제 나누나? 안 쓰는 필드가 절반 초과 목적별로 나눈다. 소비자 이름을 쓰지 않는다
인터페이스를 누가 소유하나? 제공하는 도메인 import 방향이 한쪽이어야 한다

이 표준을 정하기까지

출발은 “Controller에 Service를 바로 주입하는 게 맞나”였다. OrderServicepublic이라 Controller가 모든 public 메서드를 쥔다는 지적에서 시작했다. UseCase 인터페이스를 두면 필요한 것만 노출된다는 아이디어였다.

따져보니 이득이 하나 더 컸다. “노출을 줄인다”는 건 여전히 사람이 지키는 약속이다 — Controller가 그냥 OrderService를 주입받아도 컴파일되니까. 그런데 인터페이스를 public으로 두면 구현체를 package-private으로 만들 수 있다. 그 순간 “필요한 것만 쓴다”가 컴파일 에러로 강제된다. 원래 제안의 목적이 부수 효과였고, 진짜 이득은 가시성이었다.

그리고 이미 있는 패턴과 대칭이라는 걸 알아차렸다. domain에 Repository 인터페이스를 public으로 두고 구현체를 infra에 package-private으로 감춘 것과 완전히 같은 구조다. 새 개념을 들이는 게 아니라 같은 규칙을 한 계층 위에 한 번 더 적용하는 것이었다.

분할 단위에서는 Controller 1:1을 먼저 버렸다. 직관적으로는 “그 Controller가 쓰는 것만” 이 가장 정확해 보였는데, 그러면 api가 몇 개의 Controller로 나뉘는지가 application의 인터페이스 구성을 정하게 된다. 의존 방향이 api → application인데 구조는 거꾸로 흐르는 셈이다. 결국 이미 Command/Query로 갈라둔 선에 맞추는 게 새 기준을 만들지 않는 길이었다.

도메인 간 호출을 코드로 써 보다가 기존 표준의 모순을 발견했다. 결제가 주문을 조회하는 코드를 짜니 import order.domain.OrderDomain이 필요했는데, 패키지 구조 표준이 그걸 명시적으로 금지하고 있었다. 즉 “Service 출력은 ServiceResult<Domain>“과 “다른 도메인의 domain은 import하지 않는다”가 양립하지 않는다. 지금까지 도메인 간 호출을 실제로 안 짜봤기 때문에 드러나지 않았던 것이다.

모순을 풀면서 규칙 위반보다 실질적인 위험이 보였다. OrderDomain을 넘기면 결제가 order.cancel()을 부를 수 있다. 주문의 상태 전이가 결제 안에서 실행되는 것이다. 금지 규칙이 막으려던 게 단순한 import 위생이 아니라 로직의 유출이었다.

이름은 Info에서 Snapshot으로 바꿨다. 처음엔 관행대로 OrderInfo라고 썼는데, 이 저장소의 다른 타입 이름은 전부 무엇인지가 이름에서 읽힌다 — SearchCondition, MapperParam, Projection. 그에 비해 “정보”는 정보량이 0이다. 이 타입의 본질이 그 순간의 값을 복사해 굳힌 것이고, 그건 애그리거트 경계 표준의 “다른 애그리거트의 시점 값은 복사해서 보관한다”와 같은 성질이다. 이름에 그게 있으면 6개월 뒤에 “이거 최신값인가? 캐싱해도 되나?”를 안 묻게 된다.

마지막으로 나누는 기준에서 함정을 하나 피했다. 소비자가 늘면 Snapshot을 쪼개야 하는데, 처음엔 OrderPaymentSnapshot처럼 소비자 이름으로 나누려 했다. 그러면 주문 도메인의 타입 이름에 결제가 박히고, 방금 정한 “주문은 소비자를 모른다”가 깨진다. 소비자가 아니라 목적으로 나누면 주문은 계속 소비자를 모른 채로 있을 수 있다. 그리고 미리 나누지 않고 나눌 신호(안 쓰는 필드가 절반 초과)만 정해뒀다.


정리

  • Controller는 XxxUseCase 인터페이스만 주입받는다. 분할 단위는 기능 묶음이고, Controller 수에 맞추지 않는다
  • XxxService는 package-private이다. 이것이 없으면 인터페이스를 만든 의미가 사라진다
  • applicationdomain과 같은 모양이 된다. 인터페이스는 공개, 구현은 감춤
  • 다른 도메인에는 Domain을 넘기지 않는다. 넘기면 우리 도메인의 로직이 남의 도메인에서 실행된다
  • 도메인 간 창구는 XxxProvider, 반환 타입은 XxxSnapshot이다. 팩토리는 package-private으로 두어 제공 도메인만 만들 수 있게 한다
  • 인터페이스는 제공하는 도메인이 소유한다. import 방향이 한쪽이어야 도메인을 떼어낼 수 있다
  • Snapshot은 하나로 시작한다. 안 쓰는 필드가 절반을 넘으면 목적별로 나눈다

AI 코드 어시스턴트에 바로 적용하기

Claude Code — .claude/skills/usecase-interface-standard/SKILL.md

---
name: usecase-interface-standard
description: application 계층의 공개 표면 규칙. Controller가 주입받는 타입, Service 가시성, 도메인 간 호출 창구를 만들거나 리뷰할 때 반드시 적용한다.
---

# UseCase 인터페이스 표준

`application`의 공개 표면은 인터페이스이고, 구현체는 감춘다. `domain`/`infra`와 같은 구조다.

## Controller 주입
- Controller는 `XxxService`를 주입받지 않는다. `XxxUseCase` 인터페이스만 주입받는다.
- UseCase는 기능 묶음 단위로 나눈다. 쓰기는 `XxxCommandUseCase`, 읽기는 `XxxQueryUseCase`.
- 엔드포인트마다 인터페이스를 만들지 않는다. Controller 수에 맞춰 나누지도 않는다 — `application``api`의 구조를 알게 된다.
- UseCase 인터페이스는 `application``public`으로 둔다.

## Service 가시성
- `XxxService``public`을 붙이지 않고 package-private으로 만든다.
- 하나의 `XxxService`가 여러 UseCase 인터페이스를 구현한다.
- UseCase에 없는 내부 메서드도 package-private으로 둔다. `api`에서 보이지 않아야 한다.
- 주입이 인터페이스 타입으로 이루어지므로 구현체를 감춰도 동작한다.

## 도메인 간 호출
- 다른 도메인에 `XxxDomain`을 반환하지 않는다. 상대가 `domain` 패키지를 import해야 하고, 우리 도메인의 비즈니스 메서드를 상대가 호출할 수 있게 된다.
- 도메인 간 창구는 `XxxProvider` 인터페이스로 만들고 `application``public`으로 둔다.
- `XxxProvider`의 반환 타입은 `XxxSnapshot`으로 만들고 `application``public`으로 둔다.
- `XxxSnapshot`은 getter만 갖는다. 비즈니스 메서드를 두지 않는다.
- `XxxSnapshot``XxxView`를 섞지 않는다. `Snapshot``application`에서 우리 도메인 값을 남에게 내보내는 창구이고, `View``domain`에서 여러 도메인 값을 모아온 조회 결과다. read-model-standard를 따른다.
- `XxxSnapshot.from(XxxDomain)` 정적 팩토리는 package-private으로 둔다. 제공 도메인 안에서만 만들 수 있어야 한다.
- `XxxProvider``ServiceResult` 계열로 감싸지 않는다. 그 봉투는 `api` 경계용이다.
- Controller용 `XxxUseCase`와 도메인 간용 `XxxProvider`를 하나로 합치지 않는다. 변하는 이유가 다르다.

## 인터페이스 소유
- 도메인 간 인터페이스는 제공하는 도메인이 자기 `application`에 둔다.
- 호출하는 도메인이 인터페이스를 선언하고 제공 도메인이 구현하는 방식을 쓰지 않는다. import 방향이 뒤집혀 제공 도메인이 모든 소비자를 알게 된다.

## Snapshot 분리
- 소비자가 하나면 `XxxSnapshot` 하나로 시작한다. 미리 나누지 않는다.
- 소비자마다 쓰는 필드가 갈라지고 안 쓰는 필드가 절반을 넘으면 나눈다.
- 나눌 때는 목적으로 나눈다: `OrderAmountSnapshot`, `OrderShippingSnapshot`.
- 소비자 이름으로 나누지 않는다. `OrderPaymentSnapshot`처럼 지으면 제공 도메인이 소비자를 알게 된다.

GitHub Copilot — .github/instructions/usecase-interface-standard.instructions.md

---
description: application 계층의 공개 표면과 도메인 간 호출 규칙
applyTo: "**/application/**/*.java, **/api/**/*.java"
---

- Controller는 `XxxService`가 아니라 `XxxUseCase` 인터페이스를 주입받는다.
- UseCase는 기능 묶음 단위로 `XxxCommandUseCase`(쓰기), `XxxQueryUseCase`(읽기)로 나눈다.
- 엔드포인트 1:1이나 Controller 1:1로 UseCase를 나누지 않는다.
- UseCase 인터페이스는 `application``public`으로 둔다.
- `XxxService`는 package-private으로 선언하고 여러 UseCase 인터페이스를 구현한다.
- UseCase에 없는 Service 내부 메서드도 package-private으로 둔다.
- 다른 도메인에 `XxxDomain`을 반환하지 않는다.
- 도메인 간 창구는 `XxxProvider` 인터페이스로 만들고 `application``public`으로 둔다.
- `XxxProvider`의 반환 타입은 `XxxSnapshot`으로 만들고 getter만 갖게 한다.
- `XxxSnapshot.from(XxxDomain)` 정적 팩토리는 package-private으로 둔다.
- `XxxSnapshot`(도메인 간 창구, `application`)과 `XxxView`(조회 결과, `domain`)를 섞지 않는다.
- `XxxProvider`의 반환을 `ServiceResult` 계열로 감싸지 않는다.
- `XxxUseCase``XxxProvider`를 하나의 인터페이스로 합치지 않는다.
- 도메인 간 인터페이스는 제공하는 도메인의 `application`에 둔다. 호출하는 도메인이 선언하고 제공 도메인이 구현하는 방식을 쓰지 않는다.
- `XxxSnapshot`은 하나로 시작하고, 안 쓰는 필드가 절반을 넘으면 목적별로 나눈다.
- `XxxSnapshot`을 소비자 도메인 이름으로 나누지 않는다.

자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기