실전 프로젝트
구조 설계
Clean Architecture + DDD를 통합한 실전 프로젝트 구조 - 폴더 구조 예시와 실제 코드 구조
Clean Architecture + DDD 통합
두 개념을 하나의 프로젝트 구조로 통합하기
Clean Architecture는 레이어 간 의존성 방향을 정의하고, DDD는 도메인 레이어의 내부 구조를 정의합니다. 두 개념은 상호 보완적이며, 함께 사용할 때 더욱 강력한 아키텍처를 만들 수 있습니다.
- 의존성은 안쪽으로 — 모든 의존성은 Domain을 향함
- Domain은 순수 — 프레임워크, 라이브러리에 의존하지 않음
- 인터페이스로 경계 — Port/Adapter 패턴으로 레이어 분리
- DDD는 Domain 내부 — Aggregate, Entity, Value Object는 Domain 레이어
폴더 구조 예시
실제 프로젝트에서 사용하는 폴더 구조
├── domain/ # 핵심 비즈니스 로직
│ ├── entities/
│ │ ├── Order.ts
│ │ ├── OrderLine.ts
│ │ └── Customer.ts
│ ├── value-objects/
│ │ ├── Money.ts
│ │ ├── Address.ts
│ │ └── OrderId.ts
│ ├── aggregates/
│ │ └── OrderAggregate.ts
│ ├── repositories/ # 인터페이스만
│ │ └── OrderRepository.ts
│ ├── services/
│ │ └── PricingService.ts
│ └── events/
│ ├── OrderCreatedEvent.ts
│ └── OrderConfirmedEvent.ts
│
├── application/ # Use Cases
│ ├── use-cases/
│ │ ├── CreateOrderUseCase.ts
│ │ ├── ConfirmOrderUseCase.ts
│ │ └── CancelOrderUseCase.ts
│ ├── dtos/
│ │ ├── CreateOrderDto.ts
│ │ └── OrderResponseDto.ts
│ └── ports/ # 외부 서비스 인터페이스
│ ├── PaymentGateway.ts
│ └── NotificationService.ts
│
├── infrastructure/ # 외부 시스템 구현
│ ├── persistence/
│ │ ├── PostgresOrderRepository.ts
│ │ └── OrderMapper.ts
│ ├── external/
│ │ ├── StripePaymentGateway.ts
│ │ └── SendGridNotificationService.ts
│ └── messaging/
│ └── RabbitMQEventPublisher.ts
│
├── interface/ # 외부 인터페이스
│ ├── http/
│ │ ├── controllers/
│ │ │ └── OrderController.ts
│ │ ├── middleware/
│ │ └── presenters/
│ │ └── OrderPresenter.ts
│ └── graphql/
│ └── OrderResolver.ts
│
└── shared/ # 공유 유틸리티
├── Result.ts
├── Guard.ts
└── UniqueEntityID.ts
├── modules/ # Bounded Context별 모듈
│ ├── orders/ # Orders Bounded Context
│ │ ├── domain/
│ │ │ ├── Order.ts
│ │ │ ├── OrderRepository.ts
│ │ │ └── OrderEvents.ts
│ │ ├── application/
│ │ │ └── CreateOrderUseCase.ts
│ │ ├── infrastructure/
│ │ │ └── PostgresOrderRepository.ts
│ │ └── interface/
│ │ └── OrderController.ts
│ │
│ ├── inventory/ # Inventory Bounded Context
│ │ ├── domain/
│ │ ├── application/
│ │ ├── infrastructure/
│ │ └── interface/
│ │
│ ├── shipping/ # Shipping Bounded Context
│ │ └── ...
│ │
│ └── billing/ # Billing Bounded Context
│ └── ...
│
├── shared/ # 공유 커널
│ ├── domain/
│ │ ├── AggregateRoot.ts
│ │ ├── Entity.ts
│ │ └── ValueObject.ts
│ └── infrastructure/
│ └── EventBus.ts
│
└── main/ # 앱 진입점, DI 설정
├── app.ts
└── container.ts
각 Bounded Context가 독립적인 모듈로 분리되어 있어 팀별 독립 개발이 용이하고, 나중에 마이크로서비스로 분리하기도 쉽습니다.
├── features/ # 기능(Feature) 단위로 구성
│ ├── create-order/
│ │ ├── CreateOrderCommand.ts
│ │ ├── CreateOrderHandler.ts
│ │ ├── CreateOrderController.ts
│ │ └── CreateOrderValidator.ts
│ │
│ ├── confirm-order/
│ │ ├── ConfirmOrderCommand.ts
│ │ ├── ConfirmOrderHandler.ts
│ │ └── ConfirmOrderController.ts
│ │
│ ├── get-order/
│ │ ├── GetOrderQuery.ts
│ │ ├── GetOrderHandler.ts
│ │ └── GetOrderController.ts
│ │
│ └── cancel-order/
│ └── ...
│
├── domain/ # 공유 도메인 객체
│ ├── Order.ts
│ ├── OrderRepository.ts
│ └── value-objects/
│
├── infrastructure/
│ └── PostgresOrderRepository.ts
│
└── shared/
CQRS 패턴과 잘 어울리며, 각 기능이 독립적으로 개발/테스트됩니다. 새로운 기능 추가 시 다른 코드에 영향을 주지 않습니다.
실제 코드 구조
각 레이어별 코드 예시
Order (Aggregate Root)
Domainexport class Order extends AggregateRoot<OrderId> {
private _customerId: CustomerId;
private _lines: OrderLine[];
private _status: OrderStatus;
private _shippingAddress: Address;
private constructor(props: OrderProps, id?: OrderId) {
super(id ?? OrderId.create());
this._customerId = props.customerId;
this._lines = props.lines ?? [];
this._status = props.status ?? OrderStatus.Draft;
this._shippingAddress = props.shippingAddress;
}
static create(props: CreateOrderProps): Result<Order> {
const order = new Order({
customerId: props.customerId,
shippingAddress: props.shippingAddress,
lines: [],
status: OrderStatus.Draft,
});
order.addDomainEvent(new OrderCreatedEvent(order));
return Result.ok(order);
}
addLine(productId: ProductId, qty: number, price: Money): Result<void> {
if (this._status !== OrderStatus.Draft) {
return Result.fail('Cannot modify confirmed order');
}
this._lines.push(OrderLine.create(productId, qty, price));
return Result.ok();
}
confirm(): Result<void> {
if (this._lines.length === 0) {
return Result.fail('Cannot confirm empty order');
}
this._status = OrderStatus.Confirmed;
this.addDomainEvent(new OrderConfirmedEvent(this));
return Result.ok();
}
get totalAmount(): Money {
return this._lines.reduce(
(sum, line) => sum.add(line.subtotal),
Money.zero()
);
}
}
Money (Value Object)
Domainexport class Money extends ValueObject<MoneyProps> {
private constructor(props: MoneyProps) {
super(props);
}
get amount(): number {
return this.props.amount;
}
get currency(): Currency {
return this.props.currency;
}
static create(amount: number, currency: Currency): Result<Money> {
if (amount < 0) {
return Result.fail('Amount cannot be negative');
}
return Result.ok(new Money({ amount, currency }));
}
static zero(currency: Currency = Currency.KRW): Money {
return new Money({ amount: 0, currency });
}
add(other: Money): Money {
this.ensureSameCurrency(other);
return new Money({
amount: this.amount + other.amount,
currency: this.currency,
});
}
multiply(factor: number): Money {
return new Money({
amount: Math.round(this.amount * factor),
currency: this.currency,
});
}
private ensureSameCurrency(other: Money): void {
if (this.currency !== other.currency) {
throw new Error('Currency mismatch');
}
}
}
CreateOrderUseCase
Applicationexport class CreateOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private customerRepo: CustomerRepository,
private eventPublisher: DomainEventPublisher,
) {}
async execute(dto: CreateOrderDto): Promise<Result<OrderResponseDto>> {
// 1. 고객 존재 확인
const customer = await this.customerRepo.findById(
new CustomerId(dto.customerId)
);
if (!customer) {
return Result.fail('Customer not found');
}
// 2. 주문 생성 (도메인 로직)
const addressResult = Address.create(dto.shippingAddress);
if (addressResult.isFailure) {
return Result.fail(addressResult.error);
}
const orderResult = Order.create({
customerId: customer.id,
shippingAddress: addressResult.getValue(),
});
if (orderResult.isFailure) {
return Result.fail(orderResult.error);
}
const order = orderResult.getValue();
// 3. 주문 항목 추가
for (const item of dto.items) {
const priceResult = Money.create(item.price, Currency.KRW);
if (priceResult.isFailure) {
return Result.fail(priceResult.error);
}
order.addLine(
new ProductId(item.productId),
item.quantity,
priceResult.getValue()
);
}
// 4. 저장
await this.orderRepo.save(order);
// 5. 도메인 이벤트 발행
await this.eventPublisher.publishAll(order.domainEvents);
// 6. 응답 DTO 반환
return Result.ok(OrderResponseDto.fromDomain(order));
}
}
PostgresOrderRepository
Infrastructureexport class PostgresOrderRepository implements OrderRepository {
constructor(private db: Database) {}
async findById(id: OrderId): Promise<Order | null> {
const row = await this.db.query(
`SELECT o.*, json_agg(ol.*) as lines
FROM orders o
LEFT JOIN order_lines ol ON o.id = ol.order_id
WHERE o.id = $1
GROUP BY o.id`,
[id.value]
);
if (!row) return null;
return OrderMapper.toDomain(row);
}
async save(order: Order): Promise<void> {
const data = OrderMapper.toPersistence(order);
await this.db.transaction(async (tx) => {
// Upsert order
await tx.query(
`INSERT INTO orders (id, customer_id, status, shipping_address, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
status = $3,
shipping_address = $4,
updated_at = NOW()`,
[data.id, data.customerId, data.status, data.shippingAddress, data.createdAt]
);
// Delete and re-insert lines
await tx.query('DELETE FROM order_lines WHERE order_id = $1', [data.id]);
for (const line of data.lines) {
await tx.query(
`INSERT INTO order_lines (id, order_id, product_id, quantity, price)
VALUES ($1, $2, $3, $4, $5)`,
[line.id, data.id, line.productId, line.quantity, line.price]
);
}
});
}
nextId(): OrderId {
return OrderId.create();
}
}
OrderController
Interface@Controller('/orders')
export class OrderController {
constructor(
private createOrderUseCase: CreateOrderUseCase,
private confirmOrderUseCase: ConfirmOrderUseCase,
private getOrderUseCase: GetOrderUseCase,
) {}
@Post('/')
async createOrder(@Body() body: CreateOrderRequestBody): Promise<ApiResponse> {
const dto = CreateOrderDto.fromRequest(body);
const result = await this.createOrderUseCase.execute(dto);
if (result.isFailure) {
return ApiResponse.fail(result.error, 400);
}
return ApiResponse.created(result.getValue());
}
@Post('/:id/confirm')
async confirmOrder(@Param('id') id: string): Promise<ApiResponse> {
const result = await this.confirmOrderUseCase.execute(id);
if (result.isFailure) {
return ApiResponse.fail(result.error, 400);
}
return ApiResponse.ok(result.getValue());
}
@Get('/:id')
async getOrder(@Param('id') id: string): Promise<ApiResponse> {
const result = await this.getOrderUseCase.execute(id);
if (result.isFailure) {
return ApiResponse.fail(result.error, 404);
}
return ApiResponse.ok(result.getValue());
}
}
DI Container 설정
Infrastructure// container.ts - 의존성 주입 설정
import { Container } from 'inversify';
const container = new Container();
// Domain Layer - 인터페이스 바인딩 없음 (순수 도메인)
// Infrastructure Layer
container.bind<Database>('Database')
.to(PostgresDatabase).inSingletonScope();
container.bind<OrderRepository>('OrderRepository')
.to(PostgresOrderRepository);
container.bind<CustomerRepository>('CustomerRepository')
.to(PostgresCustomerRepository);
container.bind<DomainEventPublisher>('EventPublisher')
.to(RabbitMQEventPublisher);
container.bind<PaymentGateway>('PaymentGateway')
.to(StripePaymentGateway);
// Application Layer - Use Cases
container.bind<CreateOrderUseCase>('CreateOrderUseCase')
.to(CreateOrderUseCase);
container.bind<ConfirmOrderUseCase>('ConfirmOrderUseCase')
.to(ConfirmOrderUseCase);
// Interface Layer
container.bind<OrderController>('OrderController')
.to(OrderController);
export { container };
구조 설계 체크리스트
Clean + DDD 프로젝트 설계 시 확인할 사항들
의존성 규칙
Domain 레이어가 어떤 외부 레이어에도 의존하지 않는가?
Application 레이어가 Infrastructure에 직접 의존하지 않는가? (인터페이스를 통해서만)
프레임워크(Express, NestJS 등)가 가장 바깥 레이어에만 존재하는가?
DDD 빌딩 블록
Entity와 Value Object가 명확히 구분되어 있는가?
Aggregate Root를 통해서만 Aggregate 내부에 접근하는가?
Repository 인터페이스가 Domain에 정의되고, 구현이 Infrastructure에 있는가?
Domain Event가 Aggregate 변경 시 발행되고 있는가?
테스트 용이성
Domain 로직을 DB 없이 단위 테스트할 수 있는가?
Use Case를 Mock Repository로 테스트할 수 있는가?
외부 서비스 의존성을 쉽게 Mock할 수 있는가?
유지보수성
새로운 기능 추가 시 영향 범위가 제한적인가?
DB나 프레임워크 교체가 용이한가?
각 레이어의 책임이 명확한가?
핵심 요약
- Clean + DDD 통합 — Clean Architecture가 레이어 구조를, DDD가 Domain 내부 구조를 정의
- 4개 레이어 — Domain, Application, Infrastructure, Interface
- 폴더 구조 선택 — 레이어 기반, 모듈 기반, 기능 기반 중 프로젝트에 맞는 구조 선택
- 의존성 주입 — Container를 통해 런타임에 구현체 바인딩
- Repository 패턴 — 인터페이스는 Domain에, 구현은 Infrastructure에
- 테스트 용이성 — 각 레이어를 독립적으로 테스트 가능