**Entity**
고유한 ID로 식별되는 객체. 속성이 바뀌어도 같은 대상으로 본다.
예시: `Order(id="o-1")`는 상태가 PENDING에서 CONFIRMED로 바뀌어도 같은 주문이다.분류: Layer 9 - 아키텍처 & 설계 패턴
DDD(Domain-Driven Design)는 복잡한 비즈니스 규칙을 코드의 중심에 두기 위해 도메인을 명확한 경계로 나누고, 그 경계 안에서 같은 언어와 모델을 쓰게 만드는 설계 방법론이다. 핵심은 멋진 용어를 많이 붙이는 것이 아니라, “주문”, “결제”, “배송” 같은 업무 개념이 시간이 지나도 코드 안에서 서로 섞이지 않게 하는 것이다.
Clean Architecture가 “비즈니스 규칙이 DB, 프레임워크, HTTP에 의존하지 않게 하라”는 의존성 규칙을 제공했다면, DDD는 “그 비즈니스 규칙을 어떤 업무 경계와 언어로 나눌 것인가”를 다룬다. 그래서 두 주제는 같이 쓰이지만 역할이 다르다.
선수지식: clean-architecture.mdx(의존성 규칙, Use Case, Domain Layer). 후속: cqrs-event-sourcing.mdx, saga-pattern.mdx, msa-patterns.mdx.
작은 서비스에서는 Order, Customer, Product 같은 모델을 하나씩 만들고 여러 기능에서 공유해도 큰 문제가 없어 보인다. 하지만 서비스가 커지면 같은 단어가 팀마다 다른 의미를 갖기 시작한다.
예를 들어 이커머스에서 “주문”은 다음처럼 다르게 보인다.
| 영역 | 같은 단어 | 실제 관심사 |
|---|---|---|
| 주문 | Order | 사용자가 담은 상품, 수량, 주문 확정 가능 여부 |
| 결제 | Order | 결제 승인 금액, 승인 번호, 환불 가능 금액 |
| 배송 | Order | 출고 가능 여부, 수령인, 주소, 송장 상태 |
| 정산 | Order | 매출 인식 시점, 수수료, 세금계산서 |
초기에는 하나의 Order 모델에 paymentTxId, shippingAddress, invoiceStatus, campaignCode를 계속 붙이면 빠르다. 문제는 시간이 지난 뒤다. 배송팀이 주소 형식을 바꾸면 결제 테스트가 깨지고, 정산팀이 세금 필드를 필수로 만들면 주문 생성 API가 실패한다. 모델을 공유했지만 실제로는 서로 다른 업무 규칙을 하나의 객체에 강제로 넣은 것이다.
DDD는 이 문제를 “모델이 부족해서”가 아니라 “경계가 없어서” 생긴 문제로 본다. 하나의 거대한 모델을 더 정교하게 만들기보다, 같은 단어가 다른 의미를 갖는 지점을 찾아 경계를 나누고 각 경계 안에서만 모델을 일관되게 만든다.
Clean Architecture만 적용해도 도메인 객체가 ORM이나 Controller에 의존하지 않게 만들 수 있다. 하지만 orders/domain/Order.ts 하나가 주문, 결제, 배송, 정산 규칙을 모두 담고 있다면 의존성 방향은 깨끗해도 모델 자체는 여전히 비대하다. 레이어가 깔끔해도 업무 경계가 흐리면 변경 비용은 줄지 않는다.
DDD의 철학은 “코드는 조직이 쓰는 업무 언어의 압축본이어야 한다”는 쪽에 가깝다. 도메인 전문가가 “주문을 확정하면 결제 요청이 가능해진다”고 말한다면, 코드도 order.confirm()과 OrderConfirmed처럼 그 문장을 따라가야 한다. order.setStatus("CONFIRMED")처럼 기술적 상태값만 바꾸는 코드는 처음에는 간단하지만, 시간이 지나면 “확정”이 뜻하는 비즈니스 규칙을 여러 서비스와 if문에 흩뿌린다.
DDD에서 먼저 잡아야 하는 것은 클래스 이름이 아니라 경계다. 경계를 잘못 잡으면 뒤에서 아무리 Entity, Value Object, Repository를 잘 만들어도 복잡도가 줄지 않는다.
Ubiquitous Language(유비쿼터스 언어)는 도메인 전문가, 기획자, 개발자가 한 경계 안에서 같은 뜻으로 쓰는 언어다. 단순한 용어집이 아니라 코드에도 강제되는 언어다.
주문 경계 안에서 “확정”이 “상품이 있고, 가격이 계산됐고, 이후에는 상품을 추가할 수 없는 상태로 전환한다”는 뜻이라면 코드도 그 의미를 드러내야 한다.
// 의미가 약하다: 상태값만 바꾸는지, 규칙 검증도 하는지 알기 어렵다.order.setStatus("CONFIRMED");
// 의미가 선명하다: 주문 확정이라는 도메인 행동을 호출한다.order.confirm();첫 번째 코드는 상태 저장에는 편하지만 언어가 빈약하다. 두 번째 코드는 “확정”이라는 업무 행동이 어디에 있는지 보여 주고, 그 안에서 불변식 검증과 도메인 이벤트 발행을 모을 수 있다.
Bounded Context(경계 컨텍스트)는 특정 모델과 언어가 일관된 뜻을 갖는 범위다. “Customer”라는 단어도 주문 경계에서는 구매자, 배송 경계에서는 수령인, 정산 경계에서는 세금 정보를 가진 지급 주체일 수 있다. DDD는 이 차이를 억지로 하나의 Customer로 합치지 않는다.
[ 주문 Context ] [ 배송 Context ] [ 정산 Context ] Buyer Recipient Payer Order Shipment Invoice OrderLine DeliveryAddress TaxProfile같은 사람을 가리킬 수는 있지만, 각 Context가 알고 싶은 규칙은 다르다. 주문은 “구매 가능한가”를 보고, 배송은 “어디로 보낼 수 있는가”를 보고, 정산은 “어떤 세금 처리가 필요한가”를 본다.
상품을 예로 들어 보자. 처음에는 모든 팀이 아래 모델을 공유한다.
Product { id, name, price}3개월 뒤 요구사항이 늘어난다.
Product { id, name, price, displayName, // 카탈로그 화면 노출명 searchKeywords, // 검색팀 관심사 stockQuantity, // 재고팀 관심사 warehouseLocation, // 물류팀 관심사 settlementCategory, // 정산팀 관심사 taxCode // 세금 처리 관심사}이 모델은 재사용된 것이 아니라 여러 경계의 요구를 한 객체에 쌓은 것이다. 카탈로그팀이 displayName을 다국어 구조로 바꾸면 재고 배치가 영향을 받고, 정산팀이 taxCode를 필수로 만들면 상품 등록 화면이 깨진다. DDD식으로 나누면 모델은 작아지고 변경 이유가 분리된다.
Catalog Context: Product { id, displayName, description, searchKeywords }Inventory Context: StockItem { productId, quantity, warehouseLocation }Pricing Context: SellableItem { productId, basePrice, discountPolicy }Settlement Context: SettlementItem { productId, taxCode, category }경계 분리는 중복을 허용한다. 여러 Context에 productId가 있어도 괜찮다. 중요한 것은 같은 데이터를 한 곳에 모으는 것이 아니라, 변경 이유가 다른 규칙을 한 모델에 묶지 않는 것이다.
Context Map은 Bounded Context 사이의 의존 관계와 번역 방식을 표시한 지도다. 주문 Context가 결제 Context의 내부 모델을 직접 import하면 경계를 나눈 의미가 약해진다. 보통은 포트, API, 이벤트, Anti-Corruption Layer(외부 모델을 내부 언어로 번역하는 보호 계층)를 둔다.
주문 Context -- OrderConfirmed 이벤트 --> 배송 Context주문 Context -- PaymentPort -----------> 결제 Context주문 Context -- ACL -------------------> 외부 PG 시스템이 지도는 마이크로서비스 설계도가 아니다. 한 프로세스 안의 모듈러 모놀리스에서도 Context Map은 필요하다. 먼저 코드 경계를 검증하고, 배포 독립성이 정말 필요해졌을 때 서비스로 분리하는 편이 보통 더 안전하다.
전략적 설계가 “어디까지가 한 업무 언어인가”를 정한다면, 전술적 설계는 그 경계 안에서 객체가 규칙을 지키게 만드는 방법이다.
고유한 ID로 식별되는 객체. 속성이 바뀌어도 같은 대상으로 본다.
예시: `Order(id="o-1")`는 상태가 PENDING에서 CONFIRMED로 바뀌어도 같은 주문이다.ID가 없고 값의 조합으로 동일성을 판단하는 객체. 보통 불변으로 만든다.
예시: `Money(1000, "KRW")`, `DeliveryAddress(...)`.함께 일관성을 지켜야 하는 객체 묶음. 하나의 트랜잭션 경계가 된다.
`Order`와 여러 `OrderLine`을 함께 저장해야 주문 금액 불변식을 지킬 수 있다.Aggregate 외부에서 접근할 수 있는 유일한 진입점.
`OrderLine`을 직접 바꾸지 않고 `Order.addLine()`을 통해서만 바꾼다.도메인에서 이미 발생한 의미 있는 사건.
`OrderConfirmed`, `PaymentApproved`, `ShipmentRequested`.어떤 상황에서도 깨지면 안 되는 비즈니스 규칙.
확정된 주문에는 상품을 추가할 수 없다. 주문 총액은 음수가 될 수 없다.Entity는 “누구인가”가 중요하다. 주문 o-1은 주소가 바뀌거나 상태가 바뀌어도 같은 주문이다. 그래서 Entity에는 ID가 있고, 생명주기 동안 상태가 바뀔 수 있다.
Value Object는 “값이 같은가”가 중요하다. Money(1000, "KRW") 두 개는 같은 값이면 같은 돈 표현이다. 배송 주소도 시스템 요구에 따라 Value Object가 될 수 있다. 주소 자체의 ID를 추적하기보다 주소 값이 주문에 복사되어 주문 시점의 배송지를 보존하는 것이 중요하다면 Value Object로 보는 편이 자연스럽다.
export class Money { constructor( readonly amount: number, readonly currency: "KRW" | "USD", ) { if (amount < 0) throw new Error("금액은 0 이상이어야 합니다"); }
add(other: Money): Money { if (this.currency !== other.currency) { throw new Error("다른 통화끼리는 더할 수 없습니다"); } return new Money(this.amount + other.amount, this.currency); }}이 예제에서 Money는 DB 저장 방식이나 NestJS 데코레이터를 모른다. Clean Architecture의 Domain Layer 안에 두기 좋고, DDD 관점에서는 “돈끼리는 같은 통화만 더할 수 있다”는 작은 도메인 규칙을 담는다.
Aggregate(애그리게이트)는 함께 일관성을 지켜야 하는 객체 묶음이다. Aggregate Root(애그리게이트 루트)는 그 묶음의 대표 객체이며, 외부 코드는 Root를 통해서만 내부 객체를 바꾼다.
주문 Aggregate의 불변식을 예로 들면 다음과 같다.
외부 코드가 order.lines[0].quantity = -5처럼 내부를 직접 바꿀 수 있으면 이 규칙은 쉽게 깨진다. Root가 모든 변경을 받으면 검증, 계산, 이벤트 생성을 한곳에 모을 수 있다.
export class Order { private status: "DRAFT" | "CONFIRMED" = "DRAFT"; private lines: OrderLine[] = [];
addLine(productId: string, price: Money, quantity: number): void { if (this.status !== "DRAFT") { throw new Error("확정된 주문에는 상품을 추가할 수 없습니다"); } if (quantity <= 0) { throw new Error("수량은 1 이상이어야 합니다"); } this.lines.push(new OrderLine(productId, price, quantity)); }
confirm(): OrderConfirmed { if (this.lines.length === 0) { throw new Error("빈 주문은 확정할 수 없습니다"); } this.status = "CONFIRMED"; return new OrderConfirmed(this.totalPrice()); }
private totalPrice(): Money { return this.lines.reduce( (sum, line) => sum.add(line.subtotal()), new Money(0, "KRW"), ); }}이 코드는 완전한 NestJS 예제가 아니라 개념을 보여 주기 위한 최소 코드다. 중요한 점은 OrderLine 저장소를 따로 열어 직접 수정하지 않는다는 것이다. 주문의 핵심 불변식은 Order가 책임진다.
호출 흐름을 손으로 따라가면 Aggregate Root의 역할이 더 분명하다.
order.addLine("book", 10_000 KRW, 2) -> lines에 2개 수량이 추가되고 totalPrice 후보는 20_000 KRW
order.confirm() -> 빈 주문이 아니므로 status = CONFIRMED -> OrderConfirmed(totalPrice=20_000 KRW) 반환
order.addLine("pen", 1_000 KRW, 1) -> 이미 CONFIRMED라 예외 발생이처럼 외부 코드가 내부 배열을 직접 만지는 대신 Root의 메서드를 지나가면, 상태 변경과 이벤트 생성이 같은 규칙 아래에 묶인다.
Domain Event(도메인 이벤트)는 “무언가를 해 달라”는 명령이 아니라 “도메인에서 이미 중요한 일이 발생했다”는 기록이다. OrderConfirmed는 주문이 확정됐다는 과거형 사건이고, 배송 Context는 이 사건을 받아 ShipmentRequested를 만들 수 있다.
Order.confirm() -> OrderConfirmed(orderId, totalPrice, confirmedAt) -> 배송 Context가 수신해 출고 준비를 시작 -> 결제 Context가 수신해 결제 승인 흐름을 시작Domain Event를 쓰면 Context 간 직접 호출을 줄일 수 있다. 하지만 이벤트는 일관성 모델을 바꾼다. 주문 확정과 배송 생성이 반드시 같은 DB 트랜잭션 안에서 끝나야 한다면 이벤트 분리는 맞지 않을 수 있다. 반대로 “주문 확정은 즉시 완료하고, 배송 준비는 몇 초 뒤 따라와도 된다”면 이벤트가 경계를 느슨하게 만든다.
Aggregate는 “관련 있어 보이는 객체를 모두 넣는 상자”가 아니다. 한 트랜잭션 안에서 반드시 함께 검증해야 하는 규칙의 경계다. 너무 크게 잡으면 규칙은 모인 것처럼 보이지만 저장과 동시성 비용이 커진다.
아래처럼 Order 하나가 결제, 배송, 리뷰까지 모두 포함한다고 가정하자.
Order Aggregate - OrderLine[] - Payment[] - Shipment - DeliveryTracking[] - Review - RefundRequest[]이 구조는 “주문과 관련된 모든 것”을 한곳에 둔 것처럼 보여서 처음에는 편하다. 하지만 실제 쓰기 패턴은 다르다.
| 변경 | 빈도 | 반드시 주문 확정과 같은 트랜잭션이어야 하는가 |
|---|---|---|
| 주문 상품 추가 | checkout 중 몇 번 | 예, 주문 총액 불변식과 연결 |
| 결제 승인 상태 갱신 | 결제 재시도마다 | 대개 아니오, 결제 Context 규칙 |
| 배송 추적 이벤트 추가 | 배송 중 여러 번 | 아니오, 배송 Context 규칙 |
| 리뷰 작성 | 구매 후 1회 | 아니오, 리뷰 Context 규칙 |
모두 한 Aggregate에 넣으면 배송 추적 이벤트가 추가될 때도 거대한 주문 행이나 관련 묶음을 잠그게 된다. checkout RPS가 50이고 배송 추적 업데이트가 초당 200건 들어오는 환경이라면, 서로 다른 업무 흐름이 같은 Aggregate 저장 경합을 만든다. p95 저장 시간이 갑자기 100ms, 300ms처럼 튀는 숫자는 절대 기준이 아니라 “경계가 쓰기 패턴과 맞지 않는다”는 관찰 신호로 봐야 한다. 이 숫자는 튜닝 기준이 아니라 경계 감각을 주는 예시다. DB lock 내부 구현은 몰라도 되고, 서로 다른 업무가 같은 저장 묶음을 기다리게 되는 상황만 잡으면 된다. 핵심은 단순하다. 서로 다른 업무가 같은 주문 묶음을 자주 저장하려고 기다리면, 실제로는 관련 없는 변경까지 한 줄로 서게 된다.
분리 기준은 간단하다.
따라서 위 예시는 보통 Order, Payment, Shipment, Review를 별도 Aggregate로 나누고 OrderConfirmed, PaymentApproved, ShipmentDelivered 같은 이벤트로 연결한다.
NestJS 적용의 목표는 폴더를 DDD스럽게 꾸미는 것이 아니라 경계와 의존성을 보이게 만드는 것이다. Clean Architecture 문서의 구조를 그대로 확장하면 한 Bounded Context를 하나의 Nest module로 둘 수 있다.
src/ orders/ # 주문 Bounded Context domain/ order.ts # Entity / Aggregate Root money.ts # Value Object events/order-confirmed.ts # Domain Event order.repository.ts # Repository 인터페이스 application/ confirm-order.use-case.ts # Use Case payment.port.ts # 다른 Context로 나가는 포트 infrastructure/ typeorm-order.repository.ts # DB 구현체 typeorm-outbox.repository.ts # Domain Event outbox 구현체 typeorm-order-unit-of-work.ts # 같은 DB transaction을 묶는 어댑터 payment-http.adapter.ts # 결제 Context/외부 PG 어댑터 orders.module.ts # DI 조립부Use Case는 Aggregate를 불러와 도메인 행동을 호출하고 저장한다. 결제나 배송의 내부 테이블을 직접 조회하지 않고 포트나 이벤트로 연결한다.
| 접근 방식 | 판단 |
|---|---|
OrderRepository.findById(orderId)로 주문 Aggregate를 가져온다 | 좋음. 주문 Context 내부 모델을 사용한다. |
주문 Use Case가 payments 테이블을 직접 조회한다 | 나쁨. 결제 Context의 내부 저장 구조를 침범한다. |
PaymentPort.requestApproval(orderId, amount)를 호출한다 | 좋음. 결제 경계와 약속을 통해 연결한다. |
export class ConfirmOrderUseCase { constructor(private readonly unitOfWork: OrderUnitOfWork) {}
async execute(orderId: string): Promise<void> { await this.unitOfWork.transaction(async (tx) => { const order = await tx.orders.findById(orderId); if (!order) throw new Error("주문을 찾을 수 없습니다");
const event = order.confirm(); await tx.orders.save(order); await tx.outbox.append(event); }); }}이 포트의 TypeORM adapter는 다음처럼 DataSource.transaction()이 제공한 manager를 모든 쓰기에 공유한다.
export class TypeOrmOrderUnitOfWork implements OrderUnitOfWork { constructor(private readonly dataSource: DataSource) {}
transaction<T>(work: (tx: OrderTransaction) => Promise<T>): Promise<T> { return this.dataSource.transaction((manager) => work({ orders: new TypeOrmOrderRepository(manager), outbox: new TypeOrmOutboxRepository(manager), }), ); }}이 예제에서 ConfirmOrderUseCase는 TypeORM, Kafka, HTTP client를 몰라도 된다. Unit of Work는 여러 repository 쓰기를 하나의 커밋 경계로 묶는 포트이고, OrderUnitOfWork adapter는 주문 변경과 domain event의 outbox 기록을 같은 DB 트랜잭션에 넣는다. 원자성의 구체적인 불변식은 callback 안의 orders와 outbox가 모두 같은 transactional EntityManager만 사용한다는 것이다. 어느 한 adapter라도 전역 manager나 전역 repository를 사용하면 transaction 밖에서 쓰기가 실행될 수 있어 보장이 깨진다. 통합 테스트에서는 outbox 저장을 실패시킨 뒤 주문 변경도 rollback되는지 확인한다.
별도 relayer가 커밋된 outbox를 broker로 전달한다. 저장 후 외부 event bus를 바로 호출하면 DB와 broker 사이의 dual-write가 되므로, 그런 bus를 쓰려면 프로세스 내부 알림으로만 한정하고 외부 전달 보장과 혼동하지 않아야 한다. 어떤 저장소와 relayer를 쓸지는 orders.module.ts의 DI 조립부에서 결정한다. DDD는 여기서 “confirm이라는 도메인 행동과 OrderConfirmed라는 사건”을 선명하게 만들고, Clean Architecture는 “그 행동이 인프라 구현에 의존하지 않게” 만든다.
DDD는 모든 프로젝트에 기본값으로 넣는 장식이 아니다. CRUD가 대부분이고 업무 규칙이 단순한 관리자 화면에는 Entity, Aggregate, Domain Event를 억지로 늘리는 편이 더 비싸다. 반대로 여러 팀이 같은 단어를 다르게 쓰고, 규칙 변경이 자주 일어나며, 한 기능 변경이 다른 기능의 테스트를 반복해서 깨뜨린다면 DDD가 비용을 회수할 가능성이 커진다.
자주 생기는 오해는 다음과 같다.
| 오해 | 바로잡기 |
|---|---|
| Bounded Context는 곧 마이크로서비스다 | Bounded Context는 개념 경계다. 한 프로세스 안의 모듈로도 충분히 시작할 수 있다. |
| Ubiquitous Language는 용어집이다 | 용어집은 보조 산출물이고, 핵심은 코드의 클래스·메서드·이벤트 이름까지 같은 언어를 쓰는 것이다. |
| Aggregate는 관련 객체를 모두 담는 그래프다 | Aggregate는 트랜잭션 일관성과 불변식의 경계다. 화면 조회 모델과 다르다. |
| Domain Event는 Kafka 메시지다 | Domain Event는 도메인 의미를 가진 사건이고, Kafka는 전달 수단 중 하나다. |
| Repository는 모든 쿼리 모음이다 | Repository는 Aggregate 저장과 조회의 추상화다. 복잡한 조회는 별도 read model이 더 적합할 수 있다. |
DDD가 잘못 적용되면 문서와 코드가 오히려 무거워진다. 긴 복구 런북보다 먼저 봐야 할 신호는 다음이다.
| 실패 신호 | 의미 | 먼저 의심할 것 |
|---|---|---|
Customer, Order, Product 필드가 계속 늘고 여러 팀 변경이 한 파일에 몰린다 | 같은 단어가 여러 의미를 담고 있다 | Bounded Context 누락 |
setStatus, updateType 같은 메서드가 많고 업무 동사가 없다 | 도메인 행동이 상태 변경으로 흩어졌다 | Ubiquitous Language 부재 |
| 하나의 Aggregate 저장이 느려지고 row lock 대기가 늘어난다 | 너무 많은 쓰기 패턴을 한 트랜잭션에 묶었다 | Aggregate 과대 설계 |
| Context 사이 동기 호출 체인이 길다 | 경계는 나눴지만 런타임 장애가 함께 전파된다 | 분산 모놀리스, 이벤트/비동기 경계 검토 |
shared 패키지가 비즈니스 타입으로 커진다 | 공유 커널이 사실상 공용 도메인 모델이 됐다 | Shared Kernel 비대화 |
| Domain Service가 대부분의 규칙을 들고 Entity는 getter/setter만 있다 | 객체가 규칙을 보호하지 못한다 | Anemic Domain Model |
진단 순서는 단순하게 잡는다. 먼저 같은 단어가 다른 의미로 쓰이는지 확인하고, 그 다음 불변식이 어느 Aggregate Root에서 보호되는지 본다. 마지막으로 경계 간 통신이 직접 DB 조회나 동기 호출 체인으로 굳어졌는지 확인한다.
DDD는 “처음부터 서비스를 잘게 쪼개라”는 말이 아니다. 경계가 아직 검증되지 않았는데 MSA로 시작하면 네트워크, 배포, 관측, 데이터 정합성 비용이 먼저 생긴다. 실무에서는 모듈러 모놀리스 안에서 Bounded Context를 코드 경계로 먼저 검증한 뒤, 독립 배포가 실제로 필요한 Context만 서비스로 분리하는 흐름이 현실적이다.
[1단계] 일반 모놀리스 src/orders, src/payments, src/shared가 경계 없이 섞임
[2단계] 모듈러 모놀리스 orders Context, payments Context, shipping Context가 모듈로 분리됨 다른 Context의 DB 테이블을 직접 조회하지 않음 포트, 이벤트, ACL로만 연결함
[3단계] MSA 쓰기 부하, 팀 독립 배포, 장애 격리 요구가 확인된 Context만 서비스로 추출이 경로에서 중요한 판단 기준은 배포 단위가 아니라 변경 이유다. 주문 정책과 결제 승인 정책이 서로 다른 속도로 바뀌고 장애 격리 요구도 다르다면 분리 후보가 된다. 반대로 한 팀이 한 릴리스로 같이 바꾸고 같은 트랜잭션 일관성이 중요하다면 물리적으로 나누는 것이 오히려 비용일 수 있다.
| A | B | 차이점 |
|---|---|---|
| Entity | Value Object | Entity는 ID로 식별하고 생명주기를 가진다. Value Object는 값으로 동일성을 판단하고 보통 불변이다. |
| Aggregate | Entity | Aggregate는 일관성을 지켜야 하는 객체 묶음이고, Entity는 그 안팎에 있을 수 있는 식별 가능한 객체다. |
| Aggregate Root | 모든 Entity | Root만 외부 접근 진입점이다. 내부 Entity를 직접 수정하지 않는다. |
| Bounded Context | 마이크로서비스 | Bounded Context는 언어와 모델의 경계, 마이크로서비스는 배포 경계다. |
| Ubiquitous Language | 도메인 용어 사전 | Ubiquitous Language는 코드와 대화에 동시에 적용되는 언어다. 용어집은 보조 도구다. |
| Domain Event | 일반 메시지 | Domain Event는 도메인 의미가 있는 과거형 사건이고, 메시지 브로커는 전달 수단이다. |
| Domain Service | Application Service | Domain Service는 특정 Entity에 넣기 어려운 도메인 규칙, Application Service는 Use Case 흐름 조율이다. |
EntityManager만 사용해야 한다는 공식 규칙