DDD 적용 가이드: 주문 도메인으로 시작하기 (Nest.js + MikroORM)

DDD 적용 가이드: 주문 도메인으로 시작하기 (Nest.js + MikroORM)
DDD는 “코드 스타일”이 아니라 도메인 규칙을 언어와 코드로 고정해 운영하는 방식에 가깝다. 이 글은 주문(Order) 도메인을 예시로, 실무에서 자주 부딪히는 의사결정 포인트를 정리한다.
- 지금 우리 서비스의 복잡도는 어디서 오나? (규칙/정책/예외 케이스)
- 규칙을 어디에 모아야 유지보수가 쉬운가?
- DB/프레임워크와 무관하게 “비즈니스 규칙”을 테스트할 수 있나?
아래 글은 같은 주문 예시를 Clean Architecture 관점(의존성 규칙/경계/포트·어댑터)으로 정리한 글이다. 함께 읽으면 “규칙을 어디에 고정할지”가 더 선명해진다.
목차
- TL;DR
- DDD 란
- 왜 도입했는가: 문제와 효과
- 모델링 핵심 원칙
- 컨텍스트 협업과 이벤트 전략
- 트랜잭션/영속성 경계
- Nest.js + MikroORM 예시 코드
- 추가로 고려할 점(오버 엔지니어링 등)
- 마무리하며
TL;DR
- DDD는 폴더 규칙이 아니라, 도메인 규칙과 용어를 코드로 고정하는 운영 방식이다.
- 시작점은 Aggregate 디테일보다 Bounded Context 경계와 source of truth를 먼저 나누는 것이다.
- 주문 예시에서는
Ordering,Payment,Shipping의 책임과 이벤트 기반 상태 전이를 먼저 합의한다. - 트랜잭션 경계는 Application(Facade)에 두고, DB-이벤트 일관성은 Outbox로 보강한다.
- 복잡한 모듈에만 엄격한 규칙을 적용하고, 단순 CRUD 모듈은 과한 구조를 피한다.
DDD 란
DDD(Domain-Driven Design)는 보통 다음과 같이 설명된다.
- 도메인 모델을 중심에 두고(= 문제 영역을 잘 표현하는 모델)
- 도메인 전문가와 개발자가 공유하는 언어(유비쿼터스 언어)로 모델을 계속 다듬으며
- 그 모델이 실제 구현(코드/테스트/설계)에 그대로 반영되도록 만드는 방법론
실무에서 이 글이 집중하는 요지는 아래 세 가지다.
- 도메인 규칙(불변조건/상태 전이)을 코드베이스의 한가운데로 모으고(도메인 모델)
- 경계(Bounded Context)를 나눠 모델의 범위를 명확히 만들며
- 유비쿼터스 언어로 팀의 용어/규칙을 고정해 간다
이 글은 “주문(Order) 도메인”을 예시로, 용어/도메인 규칙/트랜잭션/이벤트를 어떻게 합의하고 코드로 고정했는지에 대해 다룬다.
왜 도입했는가: 문제와 효과
도입 배경 + 문제 정의
서비스 개발의 초기 단계를 경험하면서 도메인과 관련해 가장 어려웠던 점은 기획/기능 변경이 왔을 때 수정 포인트가 너무 넓게 퍼져 있다는 것이었다.
- 기획/기능이 자주 바뀌고, 변경이 올 때마다 API/서비스/ORM/DB/화면까지 수정이 산발적으로 발생
- 같은 단어가 팀마다 다른 의미로 쓰임 → 기획/디자인/콘텐츠/개발 간 커뮤니케이션 비용 폭증
Presentation,Application,Domain,Infrastructure같은 구조 용어도 일관되지 않아 “어디에 무엇을 둬야 하는지”가 매번 논쟁
그래서 DDD를 “큰 설계”로 도입하기보다, 먼저 네이밍과 구조(컨벤션)를 합의하고 고정하는 쪽을 선택했다.
이는 아래에서 설명할 presentation/application/domain/infrastructure/gateway/eventlistener 등과 같은 키워드로 이어진다.
적용하면서 얻은 장점
DDD(정확히는 DDD를 뒷받침하는 컨벤션)를 적용하니, 초기에 만들어야 할 파일/구성이 늘어난 것은 사실이다. 다만 기획/기능 변경이 잦았던 시기에 해당 비용을 상회하는 장점이 있었다.
- 변경이 오면 “어디를 수정해야 하는지”가 구조로 드러남 (수정 포인트 탐색 비용 감소)
- 네이밍 합의가 쉬워짐: 유비쿼터스 언어에 가까운 용어로 커뮤니케이션 정렬
- 도메인 규칙이
domain에 모이면서, 기능 추가/수정이 더 예측 가능해짐 - 애플리케이션 계층에서 조합(오케스트레이션)을 담당해, 도메인 로직이 서로 얽히는 속도가 늦어짐
모델링 핵심 원칙
아래 세 가지를 함께 관리하면 규칙이 분산되지 않고, 변경 시 수정 경계도 더 선명해진다.
유비쿼터스 언어와 모델 용어
DDD를 팀에 적용할 때 “표준 용어”부터 시작하면 팀마다 해석이 달라 오히려 합의 비용이 생긴다. 따라서 실제 코드와 회의에서 반복되는 용어를 먼저 고정한 뒤 DDD 용어에 매핑하는 방식을 택했다.
경계와 의존 방향(모델 관점)
경험상 아키텍처가 무너지는 순간은 대부분 도메인 규칙이 인프라에 끌려 들어갈 때라고 생각한다. 그래서 이 글에서는 레이어 자체 설명보다, 도메인 규칙이 구현 디테일로 끌려가지 않게 의존 방향을 고정하는 관점만 남긴다. 핵심은 의존 방향을 유지한 채, 도메인 모델의 불변조건/상태 전이/이벤트를 운영하는 것이다.
Aggregate 경계와 불변조건
모델링에서 먼저 고정해야 하는 건 “어디까지를 한 번에 일관되게 바꿀 것인가”다.
- Aggregate는 같은 트랜잭션에서 불변조건을 지켜야 하는 상태 전이 단위를 의미한다.
- Entity/VO는 Aggregate의 언어로 일관되게 표현하고, 외부 시스템 표현(응답 포맷/DB 스키마)은 바깥 경계에서 변환한다.
- 조회 최적화 요구가 커져도 Aggregate 규칙 자체를 조회 모델로 오염시키지 않는다.
컨텍스트 협업과 이벤트 전략
경계를 먼저 나누고, 그다음 이벤트의 책임(Saga vs Handler)을 분리하면 확장 시 결합을 낮출 수 있다.
Bounded Context부터 먼저 나누기
많은 팀이 DDD를 “애그리게이트부터” 시작하다가 과모델링으로 지치곤 한다. 대부분의 경우 먼저 경계를 나누고 의존 방향을 정하는 것이 ROI가 크다.
주문 도메인을 예로 들면, 보통 한 서비스 안에서도 컨텍스트가 나뉜다.
- Ordering: 주문 생성/취소/주문 상태
- Payment: 결제 수단/승인/취소/정산
- Shipping: 배송 생성/배송 상태/송장
여기서 중요한 것은 “어디가 진실(source of truth)인가?”이다. 예를 들어 결제 승인 여부는 Payment 컨텍스트의 진실이고, Ordering은 그 결과를 이벤트로 받아 상태를 전이한다.
도메인 서비스 간 직접 참조 금지. 조합은 Application의 Command Handler(유스케이스 경계)에서.
이 규칙은 SRP, High Cohesion / Low Coupling을 유지하기 위한 안전장치로 유용하다.
Saga와 Domain Event Handler 구분
핵심 분기 기준은 “이 흐름이 본 성공 조건을 좌우하는가?”다.
- 핵심 성공 조건(결제→주문 확정→재고 반영)처럼 순서/보상/재시도가 필요하면 Saga로 둔다.
- 알림/통계/로그처럼 실패해도 본 트랜잭션을 되돌릴 필요가 없으면 Domain Event Handler로 둔다.
정리하면, Saga는 여러 Command Handler를 조합하는 애플리케이션 오케스트레이션, Domain Event Handler는 반응형 후처리다. 상태 전이 규칙은 Saga/Handler가 직접 소유하지 않고, 각 도메인 Command Handler와 Entity에서 최종적으로 집행한다.
트랜잭션/영속성 경계
트랜잭션은 DDD에서 단순 구현 디테일이 아니라, 일관성(Consistency)과 불변조건(Invariant)을 지키는 경계이다.
@Transactional()을 아무 데나 붙이지 않고, **Application layer의 Command Handler(유스케이스 경계)**에만 두는 규칙이 가장 효과가 좋았다.
presentation은 얇게 유지 (검증/라우팅)application의 Command Handler가 트랜잭션 경계를 제공- 도메인 로직은 트랜잭션을 모르도록 유지
flush같은 영속성 타이밍도 유스케이스 경계(Unit of Work) 안에서만 발생
운영에서는 아래 두 가지 문제가 자주 발생한다.
- DB 커밋은 성공했는데 이벤트 발행이 실패
- 이벤트는 발행됐는데 DB 커밋이 롤백
그래서 실무 규칙을 다음처럼 단순화해 두면 안정성이 높아진다.
- 주문 변경과 Outbox 레코드 기록을 같은 트랜잭션에서 처리한다.
- 실제 이벤트 발행은 트랜잭션 커밋 이후 별도 디스패처/퍼블리셔가 재시도 가능한 방식으로 처리한다(Outbox).
- 재시도 단위와 작업 컨텍스트 단위를 맞춰 부분 성공/중복 처리를 줄인다.
또 하나의 포인트는 readOnly를 적극적으로 분리하는 것이다.
- Command 경로:
@Transactional()(쓰기) - Query 경로:
@Transactional({ readOnly: true })(읽기)
MikroORM 관점에서 “영속성 경계”를 대표하는 호출은 보통 아래로 정리된다.
persist/persistAllpersistAndFlush(즉시 flush)
Nest.js + MikroORM 예시 코드
아래 코드는 팀 코드베이스에서 흔히 쓰는 구조를 개념적으로 보여주기 위한 예시다.
특히, DDD 관점의 도메인 모델(규칙/불변조건/이벤트)에 초점을 맞췄다.
domain은 가능한 한 프레임워크/ORM에 의존하지 않고, 불변조건과 상태 전이를 중심으로 구성한다.
코드 블록은 아래 순서로 읽으면 흐름이 빠르게 잡힌다.
- VO(Value Object): 값의 유효성/연산 규칙
- Entity(Aggregate): 상태 전이와 불변조건
- Domain Exception + Presenter: 도메인 오류와 API 오류의 경계
- Repository Port: 영속성 의존 역전
- Domain Event: 상태 변화의 외부 전달 모델
VO(Value Object)
// shared/money/value-objects/money.vo.ts
export class Money {
private constructor(
private readonly _amount: number,
private readonly _currency: string,
) {}
static of(amount: number, currency: string): Money {
const normalizedCurrency = String(currency ?? "").toUpperCase();
const normalizedAmount = Number(amount);
if (!Number.isFinite(normalizedAmount) || normalizedAmount <= 0) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.MONEY_AMOUNT_INVALID,
{
details: { amount },
},
);
}
if (!normalizedCurrency) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.MONEY_CURRENCY_REQUIRED,
);
}
return new Money(normalizedAmount, normalizedCurrency);
}
}
Entity + Aggregate Root
// modules/ordering/domains/entities/aggregates/order/order.aggregate.ts
export class Order extends BaseEntity {
private constructor(
id: string,
private readonly _userId: string,
private _status: OrderStatus,
private readonly _total: Money,
private readonly _items: OrderItem[],
private _paymentId: string | null,
private readonly _orderedAt: Date,
private _paidAt: Date | null,
) {
super(id);
}
attachPayment(paymentId: string): void {
const normalized = String(paymentId ?? "").trim();
if (!normalized) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.ORDER_PAYMENT_ID_REQUIRED,
);
}
if (this._status !== OrderStatus.PENDING_PAYMENT) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.ORDER_PAYMENT_ATTACH_INVALID_STATUS,
{
message: `cannot attach payment when order is ${this._status}`,
details: { status: this._status },
},
);
}
if (this._paymentId && this._paymentId !== normalized) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.ORDER_PAYMENT_ALREADY_ATTACHED,
);
}
this._paymentId = normalized;
}
markPaid(): void {
if (this._status === OrderStatus.PAID) {
return;
}
if (this._status !== OrderStatus.PENDING_PAYMENT) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.ORDER_MARK_PAID_INVALID_STATUS,
{
message: `cannot mark paid when order is ${this._status}`,
details: { status: this._status },
},
);
}
if (!this._paymentId) {
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.ORDER_PAYMENT_NOT_ATTACHED,
);
}
this._status = OrderStatus.PAID;
this._paidAt = new Date();
}
}
Domain Exception
// shared/errors/catalogs/ordering.errors.ts
export const ORDERING_DOMAIN_ERRORS = {
ORDER_USER_ID_REQUIRED: {
code: "ORDER_USER_ID_REQUIRED",
message: "userId is required",
status: 400,
},
ORDER_ITEMS_REQUIRED: {
code: "ORDER_ITEMS_REQUIRED",
message: "order must contain at least one item",
status: 400,
},
ORDER_PAYMENT_ID_REQUIRED: {
code: "ORDER_PAYMENT_ID_REQUIRED",
message: "paymentId is required",
status: 400,
},
ORDER_PAYMENT_ATTACH_INVALID_STATUS: {
code: "ORDER_PAYMENT_ATTACH_INVALID_STATUS",
message: "cannot attach payment in current status",
status: 409,
},
ORDER_MARK_PAID_INVALID_STATUS: {
code: "ORDER_MARK_PAID_INVALID_STATUS",
message: "cannot mark paid in current status",
status: 409,
},
MONEY_AMOUNT_INVALID: {
code: "MONEY_AMOUNT_INVALID",
message: "amount must be a positive number",
status: 400,
},
MONEY_CURRENCY_REQUIRED: {
code: "MONEY_CURRENCY_REQUIRED",
message: "currency is required",
status: 400,
},
} as const satisfies Record<string, ErrorTemplate>;
// modules/ordering/domains/entities/aggregates/order/order.aggregate.ts
throw DomainErrorFactory.create(
ORDERING_DOMAIN_ERRORS.ORDER_PAYMENT_ATTACH_INVALID_STATUS,
{
message: `cannot attach payment when order is ${this._status}`,
details: { status: this._status },
},
);
// common/filters/global-http-exception.filter.ts
@Catch()
export class GlobalHttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const http = host.switchToHttp();
const req = http.getRequest<Request>();
const res = http.getResponse<Response>();
const mapped = this.mapException(exception);
const body: ErrorResponse = {
type: `about:blank#${mapped.code}`,
title: this.getTitle(mapped.status),
status: mapped.status,
detail: mapped.detail,
instance: req.originalUrl || req.url,
code: mapped.code,
timestamp: new Date().toISOString(),
};
res.status(mapped.status).json(ResponseHelper.error(body));
}
}
Repository Port
// modules/ordering/domains/repositories/i.order.repository.ts
import { Order } from "@/modules/ordering/domains/entities/aggregates/order/order.aggregate";
import type {
RepositoryGetByIdOptions,
RepositoryPageOptions,
} from "@/lib/database/repository-get-options";
export interface IOrderRepository {
persist(order: Order): Promise<void>;
findById(id: string): Promise<Order | null>;
getById(id: string, options?: RepositoryGetByIdOptions): Promise<Order>;
findRecent(options: RepositoryPageOptions<Order>): Promise<Order[]>;
findByUserId(
userId: string,
options: RepositoryPageOptions<Order>,
): Promise<Order[]>;
countAll(): Promise<number>;
}
export const IOrderRepositorySymbol = Symbol("I_ORDER_REPOSITORY");
Domain Event
// contracts/payments/events/payment-fulfillment-requested.event.ts
export const PAYMENT_FULFILLMENT_REQUESTED_EVENT_TYPE =
"PAYMENT_WEBHOOK.PAYMENT_FULFILLMENT_REQUESTED" as const;
import { PAYMENTS_APPLICATION_ERRORS } from "@/shared/errors";
import {
requireTrimmedString,
toTrimmedString,
} from "@/common/cqrs/input-normalizer";
export class PaymentFulfillmentRequestedEvent {
static readonly eventType = PAYMENT_FULFILLMENT_REQUESTED_EVENT_TYPE;
public readonly orderId: string;
constructor(input: { orderId: string }) {
this.orderId = requireTrimmedString(
input.orderId,
PAYMENTS_APPLICATION_ERRORS.PAYMENT_WEBHOOK_PAYLOAD_INVALID,
{ reason: "orderId" },
);
}
static fromRaw(
payload: Record<string, unknown>,
): PaymentFulfillmentRequestedEvent | null {
const orderId = toTrimmedString(payload.orderId);
if (!orderId) return null;
return new PaymentFulfillmentRequestedEvent({ orderId });
}
}
추가로 고려할 점(오버 엔지니어링 등)
DDD는 “항상 옳은 답”이 아니라, 팀이 도메인 복잡도를 다루는 여러 선택지 중 하나이다. 적용 범위를 잘못 잡으면 오히려 불편해진다. 아래 상황에서는 컨벤션이 과해져서 오히려 생산성이 떨어지기도 했다.
- 도메인이 단순한데
in-/out-/do-, CQRS, 이벤트, 포트/어댑터를 풀세트로 강제 - 아직 규칙이 충분히 드러나지 않았는데 구조부터 고정(설계가 아니라 “틀”이 됨)
경험에서의 타협안(추천)은 아래처럼 정리된다.
- 복잡한 도메인/변경이 잦은 모듈은 규칙을 더 엄격하게 문서화하고 리뷰 강도를 높인다.
- 단순 CRUD 성격이 강한 모듈도 기본 경계(레이어 책임, RequestContext/UoW, Outbox 경유 비동기 경로)는 유지한 채 구현 복잡도만 단계적으로 낮춘다.
- 컨벤션은 “미래 확장”이 아니라 현재의 문제를 해결하는 만큼만 도입
- Reader/BFF/Saga는 한 번에 풀세트 도입하지 않고, 복잡도가 임계치를 넘는 흐름부터 순차 도입
개인 체크리스트:
- 이 규칙(컨벤션)이 실제로 줄여주는 비용이 무엇인가?
- 새로 들어온 팀원이 구조를 이해하는 데 도움이 되는가?
- 변경이 올 때 수정 포인트가 줄었는가, 아니면 파일만 늘었는가?
- 이벤트/프로세스가 ‘후처리’인지, ‘핵심 규칙’인지 경계가 유지되는가?
마무리하며
DDD는 “특정한 폴더 구조”나 “정해진 클래스 모양”을 복제하는 것이 아니라, 우리 팀의 도메인 문제를 어떤 모델과 경계로 다룰지에 대한 합의라고 느꼈다. 나에게는 특히 유비쿼터스 언어를 고정하고, 의존 방향을 단순하게 만들고, 트랜잭션 경계를 한 곳에 모으는 것만으로도 효과가 컸다.
처음부터 풀세트를 도입하기보다, 현재 가장 불편한 지점(네이밍 혼란, 규칙의 산재, 트랜잭션/이벤트 일관성 등)부터 하나씩 “규칙으로 만들고 코드로 고정”해보는 편이 낫다고 생각한다.