Skip to content

Split View: 클린 아키텍처 완전 가이드: SOLID부터 헥사고날, DDD, 레이어드까지 — 시니어로 가는 설계 원칙

✨ Learn with Quiz
|

클린 아키텍처 완전 가이드: SOLID부터 헥사고날, DDD, 레이어드까지 — 시니어로 가는 설계 원칙

들어가며

소프트웨어 개발에서 가장 비용이 큰 것은 무엇일까요? 초기 개발이 아닙니다. 유지보수입니다. 연구에 따르면 소프트웨어 생명주기 비용의 60-80%가 유지보수에 소요됩니다. 그리고 유지보수 비용을 결정하는 가장 큰 요인이 바로 아키텍처입니다.

Robert C. Martin(Uncle Bob)은 이렇게 말했습니다. "좋은 아키텍처는 시스템을 이해하고, 개발하고, 유지보수하고, 배포하기 쉽게 만든다." 이것은 단순한 격언이 아니라, 수십 년간 소프트웨어 프로젝트의 성공과 실패를 관통하는 핵심 원칙입니다.

시니어 개발자 면접에서 "클린 아키텍처와 헥사고날 아키텍처의 차이점을 설명해 주세요", "DDD의 Bounded Context란 무엇인가요?"와 같은 질문이 빠지지 않는 이유가 여기에 있습니다. 아키텍처를 이해하는 것은 코드를 작성하는 기술을 넘어, 시스템을 설계하는 역량을 갖추는 것입니다.

이 글에서는 SOLID 원칙부터 레이어드, 클린, 헥사고날, DDD까지 — 현대 소프트웨어 설계의 핵심 패턴들을 실전 코드와 함께 심층 분석합니다. 각 패턴의 철학, 구현 방법, 장단점, 그리고 언제 무엇을 선택해야 하는지까지 다룹니다.


1. 왜 아키텍처가 중요한가

1.1 기술 부채의 실체

기술 부채(Technical Debt)는 시간이 지날수록 복리로 불어납니다. 초기에 아키텍처 없이 빠르게 개발한 프로젝트는 다음과 같은 비용 곡선을 그립니다.

비용
  ^
  |           /  아키텍처 없음
  |          /
  |         /
  |        /
  |      /    ___--- 좋은 아키텍처
  |    /  ---
  |  / --
  | /--
  +-------------------> 시간

Stripe의 2018년 연구에 따르면 개발자들은 기술 부채 처리에 업무 시간의 약 33%를 소비합니다. 이는 연간 수천억 원 규모의 생산성 손실에 해당합니다.

1.2 좋은 아키텍처의 4가지 특성

Uncle Bob이 정의한 좋은 아키텍처의 특성은 다음과 같습니다.

  1. 이해하기 쉬움 (Easy to understand) — 새로운 팀원이 빠르게 시스템을 파악
  2. 개발하기 쉬움 (Easy to develop) — 기능 추가 시 영향 범위가 제한적
  3. 유지보수하기 쉬움 (Easy to maintain) — 버그 수정과 리팩토링이 안전
  4. 배포하기 쉬움 (Easy to deploy) — 독립적 배포와 롤백 가능

1.3 시니어 면접에서의 아키텍처

시니어 개발자 면접에서 아키텍처 질문은 필수입니다. 자주 나오는 주제들입니다.

  • SOLID 원칙 각각을 실제 프로젝트에 어떻게 적용했는지
  • 레이어드 vs 클린 vs 헥사고날의 차이와 선택 기준
  • DDD를 적용한 경험과 Bounded Context 설계
  • 아키텍처 결정 기록(ADR) 작성 경험
  • 기술 부채를 어떻게 관리하고 해소했는지

이러한 질문들에 대해 단순한 이론이 아닌 실전 경험 기반의 깊이 있는 답변을 할 수 있어야 합니다.


2. SOLID 원칙 심화

SOLID는 Robert C. Martin이 정리한 객체지향 설계의 5가지 핵심 원칙입니다. 이 원칙들은 모든 아키텍처 패턴의 기초가 됩니다.

2.1 S — Single Responsibility Principle (단일 책임 원칙)

클래스는 변경의 이유가 하나여야 한다.

핵심은 "하나의 일만 한다"가 아니라 **"변경의 이유가 하나"**라는 것입니다.

BAD: 여러 변경 이유를 가진 클래스

// BAD: User 클래스가 비즈니스 로직, 영속성, 알림을 모두 담당
class UserService {
  createUser(name: string, email: string) {
    // 검증 로직
    if (!email.includes('@')) throw new Error('Invalid email')

    // DB 저장 (영속성 관심사)
    const query = `INSERT INTO users (name, email) VALUES ('${name}', '${email}')`
    database.execute(query)

    // 이메일 발송 (알림 관심사)
    const mailClient = new SmtpClient()
    mailClient.send(email, 'Welcome!', 'Thanks for joining')

    // 로깅 (인프라 관심사)
    console.log(`User created: ${name}`)
  }
}

GOOD: 책임을 분리한 구조

// GOOD: 각 클래스가 하나의 변경 이유만 가짐
class UserValidator {
  validate(name: string, email: string): void {
    if (!email.includes('@')) throw new Error('Invalid email')
    if (name.length < 2) throw new Error('Name too short')
  }
}

class UserRepository {
  save(user: User): Promise<User> {
    return this.db.users.create({ data: user })
  }
}

class WelcomeEmailSender {
  send(email: string): Promise<void> {
    return this.mailService.send({
      to: email,
      subject: 'Welcome!',
      body: 'Thanks for joining',
    })
  }
}

class CreateUserUseCase {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private emailSender: WelcomeEmailSender
  ) {}

  async execute(name: string, email: string): Promise<User> {
    this.validator.validate(name, email)
    const user = await this.repository.save({ name, email })
    await this.emailSender.send(email)
    return user
  }
}

2.2 O — Open/Closed Principle (개방-폐쇄 원칙)

확장에는 열려 있고, 수정에는 닫혀 있어야 한다.

BAD: 새 결제 수단 추가 시 기존 코드 수정 필요

// BAD: 새 결제 수단마다 if문 추가 필요
class PaymentProcessor {
  process(payment: Payment) {
    if (payment.type === 'credit_card') {
      // 카드 결제 처리
      this.processCreditCard(payment)
    } else if (payment.type === 'paypal') {
      // 페이팔 결제 처리
      this.processPayPal(payment)
    } else if (payment.type === 'bitcoin') {
      // 새 결제 수단 추가할 때마다 기존 코드 수정!
      this.processBitcoin(payment)
    }
  }
}

GOOD: 전략 패턴으로 확장에 열림

// GOOD: 새 결제 수단 추가 시 기존 코드 수정 불필요
interface PaymentStrategy {
  process(payment: Payment): Promise<PaymentResult>
}

class CreditCardStrategy implements PaymentStrategy {
  async process(payment: Payment): Promise<PaymentResult> {
    // 카드 결제 처리
    return { success: true, transactionId: 'cc-123' }
  }
}

class PayPalStrategy implements PaymentStrategy {
  async process(payment: Payment): Promise<PaymentResult> {
    // 페이팔 결제 처리
    return { success: true, transactionId: 'pp-456' }
  }
}

// 새 결제 수단은 새 클래스만 추가하면 됨
class KakaoPayStrategy implements PaymentStrategy {
  async process(payment: Payment): Promise<PaymentResult> {
    return { success: true, transactionId: 'kp-789' }
  }
}

class PaymentProcessor {
  private strategies: Map<string, PaymentStrategy>

  constructor(strategies: Map<string, PaymentStrategy>) {
    this.strategies = strategies
  }

  async process(payment: Payment): Promise<PaymentResult> {
    const strategy = this.strategies.get(payment.type)
    if (!strategy) throw new Error(`Unknown payment type: ${payment.type}`)
    return strategy.process(payment)
  }
}

2.3 L — Liskov Substitution Principle (리스코프 치환 원칙)

하위 타입은 상위 타입을 대체할 수 있어야 한다.

BAD: 정사각형-직사각형 문제

// BAD: Square가 Rectangle의 불변 조건을 위반
class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int getArea() { return width * height; }
}

class Square extends Rectangle {
    // LSP 위반: setWidth가 height도 변경
    @Override
    public void setWidth(int w) {
        this.width = w;
        this.height = w; // 부모와 다른 동작!
    }

    @Override
    public void setHeight(int h) {
        this.width = h;
        this.height = h;
    }
}

// 클라이언트 코드가 깨짐
void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    assert r.getArea() == 50; // Square이면 실패! (100)
}

GOOD: 공통 인터페이스로 해결

// GOOD: 공통 인터페이스로 설계
interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    private final int width;
    private final int height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() { return width * height; }
}

class Square implements Shape {
    private final int side;

    Square(int side) { this.side = side; }

    public int getArea() { return side * side; }
}

2.4 I — Interface Segregation Principle (인터페이스 분리 원칙)

클라이언트는 자신이 사용하지 않는 메서드에 의존하면 안 된다.

BAD: 뚱뚱한 인터페이스

// BAD: 모든 기기가 모든 기능을 구현해야 함
interface SmartDevice {
  print(): void
  scan(): void
  fax(): void
  copyDocument(): void
}

// 프린터는 scan, fax를 구현할 수 없음
class SimplePrinter implements SmartDevice {
  print() {
    /* OK */
  }
  scan() {
    throw new Error('Not supported')
  } // 강제 구현
  fax() {
    throw new Error('Not supported')
  } // 강제 구현
  copyDocument() {
    throw new Error('Not supported')
  }
}

GOOD: 분리된 인터페이스

// GOOD: 역할별 분리된 인터페이스
interface Printer {
  print(): void
}

interface Scanner {
  scan(): void
}

interface FaxMachine {
  fax(): void
}

// 필요한 인터페이스만 구현
class SimplePrinter implements Printer {
  print() {
    /* 인쇄 처리 */
  }
}

class AllInOnePrinter implements Printer, Scanner, FaxMachine {
  print() {
    /* 인쇄 */
  }
  scan() {
    /* 스캔 */
  }
  fax() {
    /* 팩스 */
  }
}

2.5 D — Dependency Inversion Principle (의존성 역전 원칙)

고수준 모듈은 저수준 모듈에 의존하면 안 된다. 둘 다 추상화에 의존해야 한다.

이것이 모든 아키텍처 패턴의 핵심입니다.

BAD: 고수준이 저수준에 직접 의존

// BAD: 비즈니스 로직이 구체적 구현에 의존
class OrderService {
  private mysqlDb = new MySQLDatabase() // 구체 클래스에 의존
  private smtpMail = new SmtpEmailClient() // 구체 클래스에 의존

  createOrder(order: Order) {
    this.mysqlDb.insert('orders', order)
    this.smtpMail.send(order.userEmail, 'Order confirmed')
  }
}
// MySQL을 PostgreSQL로 바꾸려면 OrderService 수정 필요!

GOOD: 추상화에 의존

// GOOD: 인터페이스(추상화)에 의존
interface OrderRepository {
  save(order: Order): Promise<Order>
}

interface NotificationService {
  notify(userId: string, message: string): Promise<void>
}

class OrderService {
  constructor(
    private repository: OrderRepository, // 추상화에 의존
    private notification: NotificationService // 추상화에 의존
  ) {}

  async createOrder(order: Order): Promise<Order> {
    const saved = await this.repository.save(order)
    await this.notification.notify(order.userId, 'Order confirmed')
    return saved
  }
}

// 인프라 계층에서 구현
class PostgresOrderRepository implements OrderRepository {
  async save(order: Order): Promise<Order> {
    return this.prisma.order.create({ data: order })
  }
}

class SlackNotificationService implements NotificationService {
  async notify(userId: string, message: string): Promise<void> {
    await this.slackClient.postMessage({ channel: userId, text: message })
  }
}

3. 레이어드 아키텍처

3.1 기본 구조

레이어드 아키텍처는 가장 전통적이고 널리 사용되는 패턴입니다. 계층 간 명확한 책임 분리가 핵심입니다.

┌─────────────────────────────┐
Presentation Layer      │  ← Controller, View
├─────────────────────────────┤
Application Layer       │  ← Service, DTO
├─────────────────────────────┤
Domain Layer          │  ← Entity, Business Logic
├─────────────────────────────┤
Infrastructure Layer     │  ← Repository, External API
└─────────────────────────────┘

의존성 방향: 위 → 아래 (PresentationInfrastructure)

3.2 Spring Boot 프로젝트 구조 예시

src/main/java/com/example/shop/
  controller/
    OrderController.java
  service/
    OrderService.java
  domain/
    Order.java
    OrderItem.java
  repository/
    OrderRepository.java
    JpaOrderRepository.java
  dto/
    CreateOrderRequest.java
    OrderResponse.java

3.3 구현 예시

// Presentation Layer
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @RequestBody CreateOrderRequest request) {
        OrderResponse response = orderService.createOrder(request);
        return ResponseEntity.status(201).body(response);
    }
}

// Application Layer
@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderResponse createOrder(CreateOrderRequest request) {
        Product product = productRepository.findById(request.getProductId())
            .orElseThrow(() -> new NotFoundException("Product not found"));

        Order order = Order.create(
            request.getUserId(),
            product,
            request.getQuantity()
        );

        Order saved = orderRepository.save(order);
        return OrderResponse.from(saved);
    }
}

// Domain Layer
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private String userId;
    private BigDecimal totalAmount;
    private OrderStatus status;

    public static Order create(String userId, Product product, int quantity) {
        Order order = new Order();
        order.userId = userId;
        order.totalAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity));
        order.status = OrderStatus.CREATED;
        return order;
    }

    public void cancel() {
        if (this.status != OrderStatus.CREATED) {
            throw new IllegalStateException("Only CREATED orders can be cancelled");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

3.4 장단점

장점:

  • 이해하기 쉬움 — 대부분의 개발자에게 익숙
  • 빠른 개발 — 간단한 CRUD 앱에 적합
  • 관심사 분리 — 기본적인 계층 분리 제공

단점:

  • 도메인 로직이 서비스 계층에 흩어지기 쉬움 (Anemic Domain Model)
  • 의존성이 위에서 아래로 흐르므로 DB 변경 시 영향 범위가 큼
  • 테스트 시 인프라 의존성을 모킹해야 함
  • 복잡한 비즈니스 로직을 표현하기 어려움

3.5 언제 사용하면 좋은가

  • 간단한 CRUD 애플리케이션
  • 팀 규모가 작고 빠른 프로토타이핑이 필요할 때
  • 비즈니스 로직이 복잡하지 않을 때
  • 교체 가능성이 낮은 기술 스택을 사용할 때

4. 클린 아키텍처 (Uncle Bob)

4.1 핵심 개념: 동심원 구조

Robert C. Martin이 2012년에 발표한 클린 아키텍처는 **의존성 규칙(Dependency Rule)**을 핵심으로 합니다. 모든 의존성은 안쪽을 향해야 합니다.

┌─────────────────────────────────────────────────┐
Frameworks & Drivers (Web, DB, External)│  ┌─────────────────────────────────────────┐    │
│  │  Interface Adapters (Controllers, GW)   │    │
│  │  ┌─────────────────────────────────┐    │    │
│  │  │  Application (Use Cases)        │    │    │
│  │  │  ┌─────────────────────────┐    │    │    │
│  │  │  │     Entities            │    │    │    │
│  │  │     (Domain Models)       │    │    │    │
│  │  │  └─────────────────────────┘    │    │    │
│  │  └─────────────────────────────────┘    │    │
│  └─────────────────────────────────────────┘    │
└─────────────────────────────────────────────────┘

의존성 방향: 바깥 → 안쪽 (FrameworksEntities)

4.2 각 계층의 역할

Entities (엔티티): 비즈니스 규칙을 캡슐화. 가장 변하지 않는 핵심 로직.

// Entities - 어떤 프레임워크에도 의존하지 않음
class Order {
  private items: OrderItem[] = []
  private status: OrderStatus = 'CREATED'

  addItem(product: Product, quantity: number): void {
    if (quantity <= 0) throw new Error('Quantity must be positive')
    if (this.status !== 'CREATED') throw new Error('Cannot modify confirmed order')
    this.items.push(new OrderItem(product, quantity))
  }

  get totalAmount(): Money {
    return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
  }

  confirm(): void {
    if (this.items.length === 0) throw new Error('Cannot confirm empty order')
    this.status = 'CONFIRMED'
  }
}

Use Cases (유스케이스): 애플리케이션 고유의 비즈니스 규칙. 엔티티를 조합하여 시나리오 구현.

// Use Cases
interface OrderRepository {
  save(order: Order): Promise<Order>
  findById(id: string): Promise<Order | null>
}

interface PaymentGateway {
  charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>
}

class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private paymentGateway: PaymentGateway,
    private eventPublisher: DomainEventPublisher
  ) {}

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    const order = new Order(input.userId)

    for (const item of input.items) {
      order.addItem(item.product, item.quantity)
    }

    order.confirm()

    const paymentResult = await this.paymentGateway.charge(order.totalAmount, input.paymentMethod)

    if (!paymentResult.success) {
      throw new PaymentFailedError(paymentResult.reason)
    }

    const saved = await this.orderRepo.save(order)
    await this.eventPublisher.publish(new OrderCreatedEvent(saved.id))

    return { orderId: saved.id, totalAmount: order.totalAmount.toString() }
  }
}

Interface Adapters (인터페이스 어댑터): Use Case와 외부 세계를 연결. Controller, Presenter, Gateway.

// Interface Adapter - Controller
class OrderController {
  constructor(private createOrderUseCase: CreateOrderUseCase) {}

  async handleCreateOrder(req: HttpRequest): Promise<HttpResponse> {
    try {
      const input = this.mapToInput(req.body)
      const output = await this.createOrderUseCase.execute(input)
      return { status: 201, body: output }
    } catch (error) {
      if (error instanceof PaymentFailedError) {
        return { status: 402, body: { error: error.message } }
      }
      return { status: 500, body: { error: 'Internal server error' } }
    }
  }

  private mapToInput(body: any): CreateOrderInput {
    return {
      userId: body.userId,
      items: body.items,
      paymentMethod: body.paymentMethod,
    }
  }
}

4.3 Spring Boot 프로젝트 구조

src/main/java/com/example/shop/
  domain/                    # Entities
    model/
      Order.java
      OrderItem.java
      Money.java
    event/
      OrderCreatedEvent.java
  application/               # Use Cases
    port/
      in/
        CreateOrderUseCase.java
      out/
        OrderRepository.java
        PaymentGateway.java
    service/
      OrderApplicationService.java
  adapter/                   # Interface Adapters + Frameworks
    in/
      web/
        OrderController.java
        CreateOrderRequest.java
    out/
      persistence/
        JpaOrderRepository.java
        OrderJpaEntity.java
      payment/
        StripePaymentGateway.java
  config/
    BeanConfiguration.java

4.4 NestJS 프로젝트 구조

src/
  domain/
    entities/
      order.entity.ts
      order-item.entity.ts
      money.value-object.ts
    events/
      order-created.event.ts
  application/
    ports/
      order.repository.port.ts
      payment.gateway.port.ts
    use-cases/
      create-order.use-case.ts
      cancel-order.use-case.ts
  infrastructure/
    adapters/
      in/
        http/
          order.controller.ts
          dto/
            create-order.dto.ts
      out/
        persistence/
          prisma-order.repository.ts
        payment/
          stripe-payment.gateway.ts
    config/
      module.config.ts

4.5 의존성 규칙의 위력

클린 아키텍처의 핵심은 의존성 규칙입니다. 안쪽 원은 바깥 원에 대해 아무것도 모릅니다.

// domain 계층 - 외부 의존성 ZERO
// Order.ts에 import 문을 보면:
// - express 없음
// - prisma 없음
// - typeorm 없음
// 순수 TypeScript/Java 코드만 존재

class Order {
  // 순수 비즈니스 로직만 포함
  // DB가 MySQL이든 MongoDB든 상관없음
  // 웹 프레임워크가 Express든 Fastify든 상관없음
}

이 규칙 덕분에 프레임워크나 DB를 교체해도 핵심 비즈니스 로직은 영향을 받지 않습니다.


5. 헥사고날 아키텍처 (포트 & 어댑터)

5.1 Alistair Cockburn의 원래 개념

헥사고날 아키텍처는 2005년 Alistair Cockburn이 제안한 패턴으로, 공식 이름은 "Ports and Adapters"입니다. 육각형 모양은 포트의 수가 유동적임을 표현한 것이지 정확히 6개라는 의미가 아닙니다.

              ┌──────────┐
     HTTP ──▶ │          │ ──▶ PostgreSQL
Port     CLI ──▶  │    &     │ ──▶ Redis
Domain   gRPC ──▶  │   Core   │ ──▶ Kafka
              │          │
    Test ──▶  │          │ ──▶ Mock DB
              └──────────┘

  [Driving Adapters]  [Core]  [Driven Adapters]
    (입력 측)        (비즈니스)    (출력 측)

5.2 포트와 어댑터 개념

포트(Port): 비즈니스 로직이 외부와 소통하기 위한 인터페이스. 포트는 두 종류로 나뉩니다.

  • Driving Port (인바운드 포트): 외부에서 애플리케이션을 호출하는 인터페이스
  • Driven Port (아웃바운드 포트): 애플리케이션이 외부 시스템을 호출하기 위한 인터페이스

어댑터(Adapter): 포트의 구체적 구현.

  • Driving Adapter (인바운드 어댑터): REST Controller, CLI, gRPC Handler
  • Driven Adapter (아웃바운드 어댑터): JPA Repository, HTTP Client, Message Publisher

5.3 전체 구현 예시

// ===== PORTS (인터페이스 정의) =====

// Driving Port (인바운드) - 외부가 애플리케이션을 호출
interface CreateOrderPort {
  createOrder(command: CreateOrderCommand): Promise<OrderId>
}

interface CancelOrderPort {
  cancelOrder(orderId: string): Promise<void>
}

// Driven Port (아웃바운드) - 애플리케이션이 외부를 호출
interface LoadOrderPort {
  findById(id: string): Promise<Order | null>
}

interface SaveOrderPort {
  save(order: Order): Promise<Order>
}

interface SendNotificationPort {
  sendOrderConfirmation(order: Order): Promise<void>
}

// ===== DOMAIN CORE (핵심 비즈니스 로직) =====

class Order {
  private readonly id: string
  private items: OrderItem[] = []
  private status: OrderStatus = 'PENDING'

  constructor(
    id: string,
    private readonly customerId: string
  ) {
    this.id = id
  }

  addItem(productId: string, price: Money, quantity: number): void {
    this.ensureModifiable()
    this.items.push(new OrderItem(productId, price, quantity))
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new DomainError('Cannot confirm empty order')
    }
    this.status = 'CONFIRMED'
  }

  cancel(): void {
    if (this.status === 'SHIPPED') {
      throw new DomainError('Cannot cancel shipped order')
    }
    this.status = 'CANCELLED'
  }

  private ensureModifiable(): void {
    if (this.status !== 'PENDING') {
      throw new DomainError('Order is not modifiable')
    }
  }

  get totalAmount(): Money {
    return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
  }
}

// ===== APPLICATION SERVICE (유스케이스 구현) =====

class OrderService implements CreateOrderPort, CancelOrderPort {
  constructor(
    private readonly loadOrder: LoadOrderPort,
    private readonly saveOrder: SaveOrderPort,
    private readonly notification: SendNotificationPort
  ) {}

  async createOrder(command: CreateOrderCommand): Promise<OrderId> {
    const order = new Order(generateId(), command.customerId)

    for (const item of command.items) {
      order.addItem(item.productId, new Money(item.price, 'KRW'), item.quantity)
    }

    order.confirm()
    const saved = await this.saveOrder.save(order)
    await this.notification.sendOrderConfirmation(saved)

    return saved.id
  }

  async cancelOrder(orderId: string): Promise<void> {
    const order = await this.loadOrder.findById(orderId)
    if (!order) throw new NotFoundError('Order not found')

    order.cancel()
    await this.saveOrder.save(order)
  }
}

// ===== ADAPTERS (구체적 구현) =====

// Driving Adapter - REST Controller
class OrderRestController {
  constructor(private createOrderPort: CreateOrderPort) {}

  async handlePost(req: Request, res: Response): Promise<void> {
    const command: CreateOrderCommand = {
      customerId: req.body.customerId,
      items: req.body.items,
    }
    const orderId = await this.createOrderPort.createOrder(command)
    res.status(201).json({ orderId })
  }
}

// Driven Adapter - Database
class PostgresOrderAdapter implements LoadOrderPort, SaveOrderPort {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    })
    return data ? this.toDomain(data) : null
  }

  async save(order: Order): Promise<Order> {
    await this.prisma.order.upsert({
      where: { id: order.id },
      create: this.toPersistence(order),
      update: this.toPersistence(order),
    })
    return order
  }

  private toDomain(data: PrismaOrder): Order {
    /* 매핑 */
  }
  private toPersistence(order: Order): PrismaOrderData {
    /* 매핑 */
  }
}

// Driven Adapter - Notification
class EmailNotificationAdapter implements SendNotificationPort {
  constructor(private mailer: MailClient) {}

  async sendOrderConfirmation(order: Order): Promise<void> {
    await this.mailer.send({
      to: order.customerEmail,
      subject: 'Order Confirmed',
      body: `Your order #${order.id} has been confirmed.`,
    })
  }
}

5.4 클린 아키텍처와의 비교

관점클린 아키텍처헥사고날 아키텍처
제안자Robert C. Martin (2012)Alistair Cockburn (2005)
시각화동심원 (Concentric Circles)육각형 (Hexagon)
핵심 아이디어의존성이 안쪽을 향함포트와 어댑터로 분리
계층 수4개 (Entities, Use Cases, Adapters, Frameworks)3개 (Adapters, Ports, Domain)
강조점의존성 규칙입출력 대칭성
테스트Use Case 단위 테스트 용이어댑터 교체로 테스트 용이
본질사실상 같은 원칙의 다른 표현사실상 같은 원칙의 다른 표현

두 아키텍처 모두 의존성 역전이라는 동일한 원칙에 기반합니다. 실무에서는 혼용해서 사용하는 경우가 많습니다.


6. DDD (Domain-Driven Design)

6.1 DDD란

DDD(Domain-Driven Design)는 Eric Evans가 2003년 저서에서 소개한 방법론으로, 복잡한 비즈니스 도메인을 소프트웨어 모델의 중심에 놓는 접근법입니다.

DDD는 크게 Strategic DDD(전략적 설계)와 Tactical DDD(전술적 설계)로 나뉩니다.

6.2 Strategic DDD — 큰 그림 설계

Ubiquitous Language (유비쿼터스 언어)

개발자와 비즈니스 전문가가 동일한 용어를 사용하는 것. 코드의 클래스명, 메서드명이 비즈니스 용어와 일치해야 합니다.

비즈니스: "고객이 주문을 생성하고, 결제가 승인되면 주문이 확정된다"
코드:     customer.createOrder() → payment.approve() → order.confirm()

비즈니스: "배송이 시작되면 주문을 취소할 수 없다"
코드:     order.cancel()if (status === 'SHIPPED') throw Error

Bounded Context (바운디드 컨텍스트)

하나의 시스템 안에서도 같은 용어가 다른 의미를 가질 수 있습니다. Bounded Context는 특정 모델이 유효한 경계를 명시합니다.

┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐
Order Context   │  │ Payment Context  │  │ Shipping Context│                  │  │                  │  │                  │
Order           │  │  Payment         │  │  ShipmentOrderItem       │  │  Transaction     │  │  PackageCustomer (id,   │  │  Customer (id,   │  │  Customer (id,│   name, email)   │  │   paymentMethod) │  │   address)│                  │  │                  │  │                  │
"Customer"는    │  │  "Customer"는    │  │  "Customer"는    │
│  주문자 정보     │  │  결제자 정보     │  │  수령인 정보     │
└────────┬────────┘  └────────┬────────┘  └────────┬────────┘
         │                    │                    │
         └───── Context Map (컨텍스트 매핑) ───────┘

Context Mapping 패턴

Bounded Context 간의 관계를 정의하는 패턴들입니다.

  • Shared Kernel: 두 컨텍스트가 공통 모델을 공유
  • Customer-Supplier: 한쪽이 공급자, 다른 쪽이 소비자
  • Anti-Corruption Layer (ACL): 외부 모델이 내부를 오염시키지 않도록 변환 계층 설치
  • Published Language: 잘 문서화된 공유 언어로 통합
  • Separate Ways: 통합 없이 각자 독립적으로 운영

6.3 Tactical DDD — 코드 레벨 설계

Entity (엔티티)

고유 식별자를 가지며, 생명주기 동안 식별자가 유지되는 객체.

public class Order {
    private final OrderId id;  // 고유 식별자
    private OrderStatus status;
    private List<OrderItem> items;
    private Money totalAmount;

    // 비즈니스 로직이 엔티티 안에 존재 (Rich Domain Model)
    public void addItem(Product product, int quantity) {
        validateModifiable();
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
        recalculateTotal();
    }

    public void confirm() {
        if (items.isEmpty()) {
            throw new OrderEmptyException("Cannot confirm empty order");
        }
        this.status = OrderStatus.CONFIRMED;
        registerEvent(new OrderConfirmedEvent(this.id));
    }

    // equals/hashCode는 id 기반
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        return id.equals(((Order) o).id);
    }
}

Value Object (값 객체)

식별자가 없고, 속성 값으로 동등성을 판단. 불변(Immutable).

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        Objects.requireNonNull(currency);
    }

    public Money add(Money other) {
        validateSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }

    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    private void validateSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException(
                "Cannot operate on different currencies"
            );
        }
    }
}

// 사용 예시
Money price = new Money(BigDecimal.valueOf(15000), Currency.KRW);
Money total = price.multiply(3);  // 45000 KRW

Aggregate (애그리거트)

관련 엔티티와 값 객체의 클러스터. Aggregate Root를 통해서만 접근 가능.

// Order가 Aggregate Root
// OrderItem은 Order를 통해서만 접근/수정 가능
public class Order {  // Aggregate Root
    private final OrderId id;
    private final CustomerId customerId;
    private List<OrderItem> items = new ArrayList<>();  // 내부 엔티티
    private ShippingAddress address;  // 값 객체
    private Money totalAmount;        // 값 객체

    // 외부에서 OrderItem을 직접 생성/수정 불가
    // 반드시 Order를 통해서만 조작
    public void addItem(ProductId productId, Money price, int qty) {
        OrderItem item = new OrderItem(productId, price, qty);
        this.items.add(item);
        recalculateTotal();
    }

    public void removeItem(ProductId productId) {
        this.items.removeIf(item -> item.getProductId().equals(productId));
        recalculateTotal();
    }
}

Aggregate 설계 규칙:

  1. Aggregate 바깥에서는 Root Entity만 참조
  2. Aggregate 간 참조는 ID로만 (직접 객체 참조 금지)
  3. 하나의 트랜잭션에서 하나의 Aggregate만 수정
  4. Aggregate는 작게 유지 (1 Root + 소수의 내부 객체)

Domain Event (도메인 이벤트)

도메인에서 발생한 중요한 사건을 나타내는 객체.

public record OrderConfirmedEvent(
    OrderId orderId,
    CustomerId customerId,
    Money totalAmount,
    Instant occurredAt
) implements DomainEvent {

    public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money totalAmount) {
        this(orderId, customerId, totalAmount, Instant.now());
    }
}

// Aggregate Root에서 이벤트 등록
public class Order extends AbstractAggregateRoot<Order> {

    public void confirm() {
        this.status = OrderStatus.CONFIRMED;
        registerEvent(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
    }
}

// 이벤트 핸들러
@Component
public class OrderConfirmedEventHandler {

    @EventListener
    public void handleOrderConfirmed(OrderConfirmedEvent event) {
        // 결제 처리 트리거
        // 재고 차감 트리거
        // 확인 이메일 발송
    }
}

Repository (리포지토리)

Aggregate의 영속성을 담당하는 인터페이스. Domain 계층에 인터페이스를, Infrastructure 계층에 구현을 배치합니다.

// Domain 계층에 인터페이스 정의
public interface OrderRepository {
    Order findById(OrderId id);
    OrderId save(Order order);
    void delete(Order order);
    List<Order> findByCustomerId(CustomerId customerId);
}

// Infrastructure 계층에서 구현
@Repository
public class JpaOrderRepository implements OrderRepository {

    private final SpringDataOrderRepository springDataRepo;
    private final OrderMapper mapper;

    @Override
    public Order findById(OrderId id) {
        return springDataRepo.findById(id.getValue())
            .map(mapper::toDomain)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Override
    public OrderId save(Order order) {
        OrderJpaEntity entity = mapper.toJpaEntity(order);
        OrderJpaEntity saved = springDataRepo.save(entity);
        return new OrderId(saved.getId());
    }
}

6.4 Event Storming

Event Storming은 DDD에서 도메인을 탐색하고 Bounded Context를 발견하기 위한 워크숍 기법입니다.

1. Domain Events (주황색 포스트잇) - 과거형으로 작성
   "주문이 생성되었다" "결제가 승인되었다" "상품이 배송되었다"

2. Commands (파란색 포스트잇) - 이벤트를 발생시키는 행위
   "주문 생성" "결제 승인" "배송 시작"

3. Aggregates (노란색 포스트잇) - 커맨드를 처리하는 주체
   "Order" "Payment" "Shipment"

4. Bounded Contexts 발견 - 관련 있는 것들을 그룹핑
   Order Context | Payment Context | Shipping Context

6.5 DDD + Hexagonal = 강력한 조합

DDD의 전술적 패턴을 헥사고날 아키텍처에 배치하면 가장 강력한 조합이 됩니다.

src/
  order/                          # Bounded Context
    domain/                       # 헥사고날의 Core
      model/
        Order.java                # Aggregate Root (Entity)
        OrderItem.java            # Entity
        Money.java                # Value Object
        OrderStatus.java          # Value Object (Enum)
      event/
        OrderConfirmedEvent.java  # Domain Event
      service/
        OrderDomainService.java   # Domain Service
    application/                  # 헥사고날의 Port 정의 + Use Case
      port/
        in/
          CreateOrderUseCase.java    # Driving Port
          CancelOrderUseCase.java
        out/
          LoadOrderPort.java         # Driven Port
          SaveOrderPort.java
          PaymentPort.java
      service/
        OrderApplicationService.java # Use Case 구현
    infrastructure/               # 헥사고날의 Adapter
      adapter/
        in/
          web/
            OrderController.java     # Driving Adapter
          messaging/
            OrderEventListener.java
        out/
          persistence/
            JpaOrderRepository.java  # Driven Adapter
            OrderJpaEntity.java
          payment/
            StripePaymentAdapter.java

6.6 DDD를 사용하지 말아야 할 때

DDD는 강력하지만 모든 프로젝트에 적합하지는 않습니다.

사용하지 말아야 할 경우:

  • 단순한 CRUD 애플리케이션
  • 비즈니스 로직이 거의 없는 데이터 파이프라인
  • 프로토타입이나 MVP
  • 팀이 DDD에 대한 이해가 부족할 때
  • 도메인 전문가와 협업이 불가능할 때

DDD 복잡도 판단 기준:

비즈니스 로직 복잡도
  ^
DDD + Hexagonal
  │  ┌──────────────┐
  │  │              │
  │  │  Sweet Spot  │  │              │
  │  └──────────────┘
Clean Architecture
  │  ┌──────────────┐
  │  │              │
  │  └──────────────┘
Layered
  │  ┌──────────────┐
  │  └──────────────┘
  └──────────────────────> 프로젝트 규모/팀 크기

7. 아키텍처 비교 총정리

7.1 비교표

항목레이어드클린헥사고날DDD
복잡도낮음중간중간-높음높음
학습 곡선낮음중간중간높음
적합한 프로젝트간단한 CRUD중간 복잡도복잡한 도메인, 많은 통합복잡한 비즈니스 로직
핵심 장점이해하기 쉬움프레임워크 독립어댑터 교체 가능비즈니스 정렬
테스트 용이성보통높음높음매우 높음
팀 규모소규모중규모중-대규모대규모
DB 교체 용이성어려움쉬움매우 쉬움매우 쉬움

7.2 선택 가이드 플로우차트

프로젝트 시작
  ├─ 비즈니스 로직이 단순한가? ──YES──▶ 레이어드 아키텍처
  NO
  ├─ 외부 시스템 통합이 많은가? ──YES──▶ 헥사고날 아키텍처
  NO
  ├─ 도메인 전문가와 협업 가능? ──YES──┐
  │                                    │
  NO  │                          비즈니스 로직이 매우 복잡?
  │                              │           │
YES          NO
  │                              │           │
  │                              ▼           ▼
DDD + Hexagonal  클린 아키텍처
  └──▶ 클린 아키텍처 (안전한 기본 선택)

7.3 실무 적용 팁

  1. 점진적 도입: 처음부터 DDD를 전체 적용하지 말고, 가장 복잡한 모듈부터 시작
  2. 혼합 사용: 한 시스템에서 모듈별로 다른 아키텍처 적용 가능 (핵심은 DDD, 나머지는 레이어드)
  3. 팀 역량 고려: 팀의 경험과 이해도에 맞는 아키텍처 선택
  4. YAGNI 원칙: 지금 필요하지 않은 추상화를 미리 만들지 말 것

8. 실전 프로젝트: 이커머스 주문 시스템

8.1 도메인 모델 설계

Clean/Hexagonal + DDD를 적용한 이커머스 주문 시스템의 전체 구현입니다.

Money (Value Object)

public record Money(BigDecimal amount, String currency) {

    public Money {
        Objects.requireNonNull(amount, "Amount must not be null");
        Objects.requireNonNull(currency, "Currency must not be null");
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
    }

    public static Money of(long amount, String currency) {
        return new Money(BigDecimal.valueOf(amount), currency);
    }

    public static Money zero(String currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money add(Money other) {
        assertSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }

    public boolean isGreaterThan(Money other) {
        assertSameCurrency(other);
        return this.amount.compareTo(other.amount) > 0;
    }

    private void assertSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                "Currency mismatch: " + this.currency + " vs " + other.currency
            );
        }
    }
}

OrderItem (Entity)

public class OrderItem {
    private final String productId;
    private final String productName;
    private final Money unitPrice;
    private int quantity;

    public OrderItem(String productId, String productName, Money unitPrice, int quantity) {
        if (quantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
        this.productId = productId;
        this.productName = productName;
        this.unitPrice = unitPrice;
        this.quantity = quantity;
    }

    public Money getSubtotal() {
        return unitPrice.multiply(quantity);
    }

    public void changeQuantity(int newQuantity) {
        if (newQuantity <= 0) throw new IllegalArgumentException("Quantity must be positive");
        this.quantity = newQuantity;
    }
}

Order (Aggregate Root)

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items = new ArrayList<>();
    private OrderStatus status;
    private Money totalAmount;
    private final Instant createdAt;
    private final List<DomainEvent> domainEvents = new ArrayList<>();

    public Order(String id, String customerId) {
        this.id = id;
        this.customerId = customerId;
        this.status = OrderStatus.PENDING;
        this.totalAmount = Money.zero("KRW");
        this.createdAt = Instant.now();
    }

    public void addItem(String productId, String productName, Money price, int quantity) {
        ensureModifiable();
        this.items.add(new OrderItem(productId, productName, price, quantity));
        recalculateTotal();
    }

    public void confirm() {
        if (items.isEmpty()) {
            throw new OrderDomainException("Cannot confirm empty order");
        }
        if (status != OrderStatus.PENDING) {
            throw new OrderDomainException("Only pending orders can be confirmed");
        }
        this.status = OrderStatus.CONFIRMED;
        domainEvents.add(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
    }

    public void cancel() {
        if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
            throw new OrderDomainException("Cannot cancel order in status: " + status);
        }
        this.status = OrderStatus.CANCELLED;
        domainEvents.add(new OrderCancelledEvent(this.id, this.customerId));
    }

    public void markAsShipped(String trackingNumber) {
        if (status != OrderStatus.CONFIRMED) {
            throw new OrderDomainException("Only confirmed orders can be shipped");
        }
        this.status = OrderStatus.SHIPPED;
        domainEvents.add(new OrderShippedEvent(this.id, trackingNumber));
    }

    private void ensureModifiable() {
        if (status != OrderStatus.PENDING) {
            throw new OrderDomainException("Order is not modifiable");
        }
    }

    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.zero("KRW"), Money::add);
    }

    public List<DomainEvent> getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    public void clearDomainEvents() {
        domainEvents.clear();
    }
}

8.2 Application Layer (Use Cases)

// Driving Port
public interface CreateOrderUseCase {
    CreateOrderResult execute(CreateOrderCommand command);
}

public record CreateOrderCommand(
    String customerId,
    List<OrderItemCommand> items
) {}

public record OrderItemCommand(
    String productId,
    String productName,
    long price,
    int quantity
) {}

// Driven Ports
public interface OrderRepository {
    Optional<Order> findById(String id);
    Order save(Order order);
}

public interface EventPublisher {
    void publish(List<DomainEvent> events);
}

// Use Case 구현
@Service
@Transactional
public class CreateOrderService implements CreateOrderUseCase {

    private final OrderRepository orderRepository;
    private final EventPublisher eventPublisher;
    private final IdGenerator idGenerator;

    public CreateOrderService(
            OrderRepository orderRepository,
            EventPublisher eventPublisher,
            IdGenerator idGenerator) {
        this.orderRepository = orderRepository;
        this.eventPublisher = eventPublisher;
        this.idGenerator = idGenerator;
    }

    @Override
    public CreateOrderResult execute(CreateOrderCommand command) {
        Order order = new Order(idGenerator.generate(), command.customerId());

        for (OrderItemCommand item : command.items()) {
            order.addItem(
                item.productId(),
                item.productName(),
                Money.of(item.price(), "KRW"),
                item.quantity()
            );
        }

        order.confirm();
        Order saved = orderRepository.save(order);
        eventPublisher.publish(saved.getDomainEvents());
        saved.clearDomainEvents();

        return new CreateOrderResult(saved.getId(), saved.getTotalAmount().toString());
    }
}

// CancelOrderUseCase
public interface CancelOrderUseCase {
    void execute(String orderId);
}

@Service
@Transactional
public class CancelOrderService implements CancelOrderUseCase {

    private final OrderRepository orderRepository;
    private final EventPublisher eventPublisher;

    @Override
    public void execute(String orderId) {
        Order order = orderRepository.findById(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        order.cancel();
        orderRepository.save(order);
        eventPublisher.publish(order.getDomainEvents());
        order.clearDomainEvents();
    }
}

8.3 Infrastructure Layer (Adapters)

// Driven Adapter - JPA Repository
@Repository
public class JpaOrderRepositoryAdapter implements OrderRepository {

    private final SpringDataOrderRepository jpaRepo;
    private final OrderPersistenceMapper mapper;

    @Override
    public Optional<Order> findById(String id) {
        return jpaRepo.findById(id).map(mapper::toDomain);
    }

    @Override
    public Order save(Order order) {
        OrderJpaEntity entity = mapper.toJpaEntity(order);
        OrderJpaEntity saved = jpaRepo.save(entity);
        return mapper.toDomain(saved);
    }
}

// Driven Adapter - Kafka Event Publisher
@Component
public class KafkaEventPublisher implements EventPublisher {

    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public void publish(List<DomainEvent> events) {
        for (DomainEvent event : events) {
            String topic = resolveTopic(event);
            String payload = serialize(event);
            kafkaTemplate.send(topic, event.getAggregateId(), payload);
        }
    }

    private String resolveTopic(DomainEvent event) {
        if (event instanceof OrderConfirmedEvent) return "order.confirmed";
        if (event instanceof OrderCancelledEvent) return "order.cancelled";
        if (event instanceof OrderShippedEvent) return "order.shipped";
        return "order.unknown";
    }

    private String serialize(DomainEvent event) {
        try {
            return objectMapper.writeValueAsString(event);
        } catch (Exception e) {
            throw new EventPublishException("Failed to serialize event", e);
        }
    }
}

// Driving Adapter - REST Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;
    private final CancelOrderUseCase cancelOrderUseCase;

    @PostMapping
    public ResponseEntity<CreateOrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = request.toCommand();
        CreateOrderResult result = createOrderUseCase.execute(command);
        return ResponseEntity.status(201).body(CreateOrderResponse.from(result));
    }

    @DeleteMapping("/{orderId}")
    public ResponseEntity<Void> cancelOrder(@PathVariable String orderId) {
        cancelOrderUseCase.execute(orderId);
        return ResponseEntity.noContent().build();
    }
}

9. 테스트 전략

9.1 도메인 로직 단위 테스트

도메인 로직은 외부 의존성 없이 순수하게 테스트할 수 있습니다. 이것이 클린/헥사고날 아키텍처의 가장 큰 장점입니다.

class OrderTest {

    @Test
    void shouldCalculateTotalAmount() {
        Order order = new Order("order-1", "customer-1");
        order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 2);
        order.addItem("prod-2", "Mouse", Money.of(30000, "KRW"), 1);

        assertEquals(Money.of(130000, "KRW"), order.getTotalAmount());
    }

    @Test
    void shouldNotConfirmEmptyOrder() {
        Order order = new Order("order-1", "customer-1");

        assertThrows(OrderDomainException.class, () -> order.confirm());
    }

    @Test
    void shouldNotCancelShippedOrder() {
        Order order = createConfirmedOrder();
        order.markAsShipped("TRACK-123");

        assertThrows(OrderDomainException.class, () -> order.cancel());
    }

    @Test
    void shouldRegisterDomainEventOnConfirm() {
        Order order = new Order("order-1", "customer-1");
        order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 1);
        order.confirm();

        List<DomainEvent> events = order.getDomainEvents();
        assertEquals(1, events.size());
        assertInstanceOf(OrderConfirmedEvent.class, events.get(0));
    }
}

class MoneyTest {

    @Test
    void shouldAddSameCurrency() {
        Money a = Money.of(1000, "KRW");
        Money b = Money.of(2000, "KRW");

        assertEquals(Money.of(3000, "KRW"), a.add(b));
    }

    @Test
    void shouldRejectDifferentCurrency() {
        Money krw = Money.of(1000, "KRW");
        Money usd = Money.of(1, "USD");

        assertThrows(IllegalArgumentException.class, () -> krw.add(usd));
    }

    @Test
    void shouldRejectNegativeAmount() {
        assertThrows(IllegalArgumentException.class,
            () -> new Money(BigDecimal.valueOf(-1), "KRW"));
    }
}

9.2 Use Case 통합 테스트

class CreateOrderServiceTest {

    private CreateOrderService sut;
    private OrderRepository fakeRepository;
    private EventPublisher fakePublisher;

    @BeforeEach
    void setUp() {
        fakeRepository = new InMemoryOrderRepository();
        fakePublisher = new FakeEventPublisher();
        sut = new CreateOrderService(fakeRepository, fakePublisher, new UuidIdGenerator());
    }

    @Test
    void shouldCreateAndConfirmOrder() {
        CreateOrderCommand command = new CreateOrderCommand(
            "customer-1",
            List.of(new OrderItemCommand("prod-1", "Keyboard", 50000, 2))
        );

        CreateOrderResult result = sut.execute(command);

        assertNotNull(result.orderId());
        Order saved = fakeRepository.findById(result.orderId()).orElseThrow();
        assertEquals(OrderStatus.CONFIRMED, saved.getStatus());
    }
}

// 테스트용 In-Memory Repository
class InMemoryOrderRepository implements OrderRepository {
    private final Map<String, Order> store = new HashMap<>();

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Order save(Order order) {
        store.put(order.getId(), order);
        return order;
    }
}

9.3 ArchUnit으로 아키텍처 테스트

ArchUnit을 사용하면 아키텍처 규칙을 자동으로 검증할 수 있습니다.

class ArchitectureTest {

    private final JavaClasses classes = new ClassFileImporter()
        .importPackages("com.example.shop");

    @Test
    void domainShouldNotDependOnInfrastructure() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infrastructure..")
            .check(classes);
    }

    @Test
    void domainShouldNotDependOnApplication() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..application..")
            .check(classes);
    }

    @Test
    void domainShouldNotUseSpringAnnotations() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().beAnnotatedWith("org.springframework.stereotype.Service")
            .orShould().beAnnotatedWith("org.springframework.stereotype.Component")
            .check(classes);
    }

    @Test
    void useCasesShouldOnlyBeAccessedByAdapters() {
        classes()
            .that().resideInAPackage("..application.service..")
            .should().onlyBeAccessed().byClassesThat()
            .resideInAnyPackage("..adapter..", "..config..", "..application..")
            .check(classes);
    }
}

9.4 테스트 피라미드

          /  E2E Tests  \          ← 적게, 느림, 비쌈
         / (Cypress, etc) \
        /─────────────────\
       / Integration Tests \       ← 중간
      /  (Adapter Tests)    \
     /───────────────────────\
    /     Unit Tests          \    ← 많이, 빠름, 저렴
   /  (Domain + Use Case)     \
  /────────────────────────────\

클린/헥사고날 아키텍처에서는 도메인 로직 단위 테스트가 전체 테스트의 대부분을 차지합니다. 이 테스트들은 외부 의존성이 없어 매우 빠르고 안정적입니다.


10. 안티패턴과 주의점

10.1 Over-Engineering (과도한 엔지니어링)

가장 흔한 실수입니다. 간단한 CRUD 앱에 DDD + Hexagonal을 적용하면 생산성이 오히려 떨어집니다.

// BAD: User의 이름을 바꾸는데 7개 파일이 필요?
// Command -> CommandHandler -> UseCase -> DomainService
// -> Repository -> Adapter -> Mapper
// 단순 업데이트에는 과도함!

// GOOD: 복잡도에 맞는 아키텍처 선택
// 간단한 CRUD = 레이어드 + 서비스 패턴으로 충분

10.2 Anemic Domain Model (빈혈 도메인 모델)

엔티티에 getter/setter만 있고 비즈니스 로직이 서비스 계층에 흩어진 패턴입니다. Martin Fowler가 명명한 안티패턴입니다.

// BAD: Anemic Domain Model
class Order {
    private String status;
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

class OrderService {
    void cancelOrder(Order order) {
        // 비즈니스 로직이 서비스에 존재
        if (order.getStatus().equals("SHIPPED")) {
            throw new Exception("Cannot cancel");
        }
        order.setStatus("CANCELLED");
    }
}

// GOOD: Rich Domain Model
class Order {
    private OrderStatus status;

    public void cancel() {
        // 비즈니스 로직이 엔티티 안에 존재
        if (this.status == OrderStatus.SHIPPED) {
            throw new OrderDomainException("Cannot cancel shipped order");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

10.3 God Class (신 클래스)

하나의 클래스가 너무 많은 책임을 가진 패턴. SRP 위반의 극단적 형태입니다.

// BAD: 하나의 서비스가 모든 것을 처리
class OrderGodService {
    void createOrder() { /* ... */ }
    void cancelOrder() { /* ... */ }
    void processPayment() { /* ... */ }
    void sendNotification() { /* ... */ }
    void generateInvoice() { /* ... */ }
    void updateInventory() { /* ... */ }
    void calculateShipping() { /* ... */ }
    // ... 수천 줄의 메서드
}

// GOOD: 책임별 분리
class CreateOrderUseCase { /* 주문 생성만 */ }
class CancelOrderUseCase { /* 주문 취소만 */ }
class PaymentService { /* 결제만 */ }
class NotificationService { /* 알림만 */ }

10.4 Leaky Abstraction (추상화 누수)

인프라 세부사항이 도메인 계층으로 누출되는 패턴.

// BAD: JPA 어노테이션이 도메인 엔티티에 존재
@Entity
@Table(name = "orders")
class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<OrderItem> items;
    // JPA라는 인프라 세부사항이 도메인에 누출
}

// GOOD: 도메인 엔티티와 JPA 엔티티 분리
// domain/Order.java - 순수 도메인 객체
class Order {
    private final String id;
    private List<OrderItem> items;
    // 프레임워크 의존성 없음
}

// infrastructure/OrderJpaEntity.java - JPA 전용
@Entity @Table(name = "orders")
class OrderJpaEntity {
    @Id private String id;
    @OneToMany(cascade = CascadeType.ALL)
    private List<OrderItemJpaEntity> items;
}

10.5 Resume-Driven Architecture (이력서 주도 아키텍처)

프로젝트 요구사항이 아닌, 이력서를 화려하게 꾸미기 위해 과도한 기술을 도입하는 패턴.

"우리 프로젝트에는 마이크로서비스 + CQRS + Event Sourcing +
Saga Pattern + GraphQL Federation이 필요해!"

실제 요구사항: 사용자 5명의 사내 도구

결과: 6개월 후에도 배포 못 함

원칙: 문제에 맞는 해결책을 선택하라.


퀴즈

아래 퀴즈로 이해도를 점검해 보세요.

Q1: SOLID에서 의존성 역전 원칙(DIP)의 핵심은 무엇인가요?

A: 고수준 모듈(비즈니스 로직)이 저수준 모듈(DB, 외부 서비스)에 직접 의존하지 않고, 둘 다 **추상화(인터페이스)**에 의존해야 한다는 원칙입니다. 이것이 클린 아키텍처와 헥사고날 아키텍처의 핵심 기반입니다.

Q2: 클린 아키텍처의 의존성 규칙(Dependency Rule)을 설명해 주세요.

A: 모든 소스코드 의존성은 **안쪽(고수준)**을 향해야 합니다. Frameworks(바깥) -> Adapters -> Use Cases -> Entities(안쪽). 안쪽 원은 바깥 원에 대해 아무것도 알지 못합니다. Entity는 Use Case를 모르고, Use Case는 Controller를 모릅니다.

Q3: 헥사고날 아키텍처에서 Driving Adapter와 Driven Adapter의 차이는 무엇인가요?

A: **Driving Adapter(인바운드)**는 외부에서 애플리케이션을 호출하는 어댑터입니다 (REST Controller, CLI, gRPC Handler). **Driven Adapter(아웃바운드)**는 애플리케이션이 외부 시스템을 호출하기 위한 어댑터입니다 (DB Repository, HTTP Client, Message Publisher). 입력과 출력의 대칭성이 핵심입니다.

Q4: DDD에서 Aggregate의 설계 규칙 4가지를 설명해 주세요.

A:

  1. Aggregate 바깥에서는 오직 Root Entity만 참조합니다
  2. Aggregate 간 참조는 ID로만 합니다 (직접 객체 참조 금지)
  3. 하나의 트랜잭션에서 하나의 Aggregate만 수정합니다
  4. Aggregate는 작게 유지합니다 (1 Root + 소수의 내부 객체)

이 규칙들은 데이터 일관성을 보장하고 확장성을 확보하기 위한 것입니다.

Q5: Anemic Domain Model이 안티패턴인 이유는 무엇인가요?

A: Anemic Domain Model은 엔티티에 getter/setter만 있고 비즈니스 로직이 서비스 계층에 흩어진 패턴입니다. 이것이 안티패턴인 이유는: (1) 비즈니스 규칙이 여러 서비스에 중복/분산되어 일관성이 깨지기 쉽고, (2) 도메인 객체가 단순 데이터 컨테이너로 전락하여 OOP의 장점(캡슐화)을 잃으며, (3) 비즈니스 규칙 변경 시 영향 범위 파악이 어려워집니다. Rich Domain Model에서는 비즈니스 로직이 엔티티 안에 존재하여 응집도가 높고 변경에 안전합니다.


참고 자료

도서

  1. Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design (2017)
  2. Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
  3. Vaughn Vernon, Implementing Domain-Driven Design (2013)
  4. Vaughn Vernon, Domain-Driven Design Distilled (2016)
  5. Martin Fowler, Patterns of Enterprise Application Architecture (2002)
  6. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship (2008)

아티클 및 블로그

  1. Alistair Cockburn, Hexagonal Architecturehttps://alistair.cockburn.us/hexagonal-architecture/
  2. Robert C. Martin, The Clean Architecture (2012) — https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  3. Martin Fowler, AnemicDomainModelhttps://martinfowler.com/bliki/AnemicDomainModel.html
  4. Martin Fowler, BoundedContexthttps://martinfowler.com/bliki/BoundedContext.html

도구 및 프레임워크

  1. ArchUnit — https://www.archunit.org/
  2. Spring Modulith — https://spring.io/projects/spring-modulith
  3. NestJS CQRS Module — https://docs.nestjs.com/recipes/cqrs

강의 및 영상

  1. Uncle Bob, Clean Architecture and Design (NDC Conference)
  2. Vaughn Vernon, Strategic Domain-Driven Design (InfoQ)
  3. Alberto Brandolini, Event Storminghttps://www.eventstorming.com/
  4. Netflix Engineering Blog, Domain-Driven Design at Netflix

Clean Architecture Complete Guide: From SOLID to Hexagonal, DDD, and Layered — Design Principles for Senior Engineers

Introduction

What is the most expensive part of software development? It is not the initial build. It is maintenance. Research shows that 60-80% of the total software lifecycle cost goes into maintenance. And the single biggest factor determining maintenance cost is architecture.

Robert C. Martin (Uncle Bob) stated: "Good architecture makes the system easy to understand, easy to develop, easy to maintain, and easy to deploy." This is not just a motto — it is a core principle that runs through decades of software project successes and failures.

There is a reason why questions like "Explain the difference between Clean Architecture and Hexagonal Architecture" and "What is a Bounded Context in DDD?" are standard in senior engineer interviews. Understanding architecture goes beyond writing code — it is about having the ability to design systems.

In this guide, we will deeply analyze the core patterns of modern software design — from SOLID principles to Layered, Clean, Hexagonal, and DDD — with production-ready code examples. We will cover the philosophy, implementation, trade-offs, and when to choose which pattern.


1. Why Architecture Matters

1.1 The Reality of Technical Debt

Technical debt compounds over time. Projects that start without architectural thinking follow a steep cost curve:

Cost
  ^
  |           /  No architecture
  |          /
  |         /
  |        /
  |      /    ___--- Good architecture
  |    /  ---
  |  / --
  | /--
  +-------------------> Time

A 2018 study by Stripe found that developers spend approximately 33% of their work time dealing with technical debt. This translates to billions of dollars in lost productivity annually.

1.2 Four Characteristics of Good Architecture

Uncle Bob defined the characteristics of good architecture as:

  1. Easy to understand — New team members can quickly grasp the system
  2. Easy to develop — Feature additions have limited blast radius
  3. Easy to maintain — Bug fixes and refactoring are safe
  4. Easy to deploy — Independent deployments and rollbacks are possible

1.3 Architecture in Senior Interviews

Architecture questions are mandatory in senior engineering interviews. Common topics include:

  • How you applied each SOLID principle in real projects
  • Differences between Layered, Clean, and Hexagonal and your selection criteria
  • Experience applying DDD and designing Bounded Contexts
  • Writing Architecture Decision Records (ADRs)
  • How you managed and resolved technical debt

You need to give answers with depth rooted in real experience, not just textbook theory.


2. SOLID Principles Deep Dive

SOLID represents the five core principles of object-oriented design, formulated by Robert C. Martin. These principles form the foundation of every architecture pattern.

2.1 S — Single Responsibility Principle

A class should have only one reason to change.

The key insight is not "do only one thing" but rather "have only one reason to change."

BAD: A class with multiple reasons to change

// BAD: UserService handles business logic, persistence, and notifications
class UserService {
  createUser(name: string, email: string) {
    // Validation logic
    if (!email.includes('@')) throw new Error('Invalid email')

    // DB persistence (persistence concern)
    const query = `INSERT INTO users (name, email) VALUES ('${name}', '${email}')`
    database.execute(query)

    // Email sending (notification concern)
    const mailClient = new SmtpClient()
    mailClient.send(email, 'Welcome!', 'Thanks for joining')

    // Logging (infrastructure concern)
    console.log(`User created: ${name}`)
  }
}

GOOD: Separated responsibilities

// GOOD: Each class has only one reason to change
class UserValidator {
  validate(name: string, email: string): void {
    if (!email.includes('@')) throw new Error('Invalid email')
    if (name.length < 2) throw new Error('Name too short')
  }
}

class UserRepository {
  save(user: User): Promise<User> {
    return this.db.users.create({ data: user })
  }
}

class WelcomeEmailSender {
  send(email: string): Promise<void> {
    return this.mailService.send({
      to: email,
      subject: 'Welcome!',
      body: 'Thanks for joining',
    })
  }
}

class CreateUserUseCase {
  constructor(
    private validator: UserValidator,
    private repository: UserRepository,
    private emailSender: WelcomeEmailSender
  ) {}

  async execute(name: string, email: string): Promise<User> {
    this.validator.validate(name, email)
    const user = await this.repository.save({ name, email })
    await this.emailSender.send(email)
    return user
  }
}

2.2 O — Open/Closed Principle

Software entities should be open for extension but closed for modification.

BAD: Adding new payment methods requires modifying existing code

// BAD: Every new payment method requires another if statement
class PaymentProcessor {
  process(payment: Payment) {
    if (payment.type === 'credit_card') {
      this.processCreditCard(payment)
    } else if (payment.type === 'paypal') {
      this.processPayPal(payment)
    } else if (payment.type === 'bitcoin') {
      // Modifying existing code for every new payment method!
      this.processBitcoin(payment)
    }
  }
}

GOOD: Strategy pattern makes it open for extension

// GOOD: Adding new payment methods requires no modification to existing code
interface PaymentStrategy {
  process(payment: Payment): Promise<PaymentResult>
}

class CreditCardStrategy implements PaymentStrategy {
  async process(payment: Payment): Promise<PaymentResult> {
    return { success: true, transactionId: 'cc-123' }
  }
}

class PayPalStrategy implements PaymentStrategy {
  async process(payment: Payment): Promise<PaymentResult> {
    return { success: true, transactionId: 'pp-456' }
  }
}

// New payment method = just add a new class
class KakaoPayStrategy implements PaymentStrategy {
  async process(payment: Payment): Promise<PaymentResult> {
    return { success: true, transactionId: 'kp-789' }
  }
}

class PaymentProcessor {
  private strategies: Map<string, PaymentStrategy>

  constructor(strategies: Map<string, PaymentStrategy>) {
    this.strategies = strategies
  }

  async process(payment: Payment): Promise<PaymentResult> {
    const strategy = this.strategies.get(payment.type)
    if (!strategy) throw new Error(`Unknown payment type: ${payment.type}`)
    return strategy.process(payment)
  }
}

2.3 L — Liskov Substitution Principle

Subtypes must be substitutable for their base types.

BAD: The Square-Rectangle problem

// BAD: Square violates Rectangle's invariants
class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int w) { this.width = w; }
    public void setHeight(int h) { this.height = h; }
    public int getArea() { return width * height; }
}

class Square extends Rectangle {
    // LSP violation: setWidth also changes height
    @Override
    public void setWidth(int w) {
        this.width = w;
        this.height = w; // Different behavior from parent!
    }

    @Override
    public void setHeight(int h) {
        this.width = h;
        this.height = h;
    }
}

// Client code breaks
void resize(Rectangle r) {
    r.setWidth(5);
    r.setHeight(10);
    assert r.getArea() == 50; // Fails for Square! (100)
}

GOOD: Common interface approach

// GOOD: Design with a common interface
interface Shape {
    int getArea();
}

class Rectangle implements Shape {
    private final int width;
    private final int height;

    Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public int getArea() { return width * height; }
}

class Square implements Shape {
    private final int side;

    Square(int side) { this.side = side; }

    public int getArea() { return side * side; }
}

2.4 I — Interface Segregation Principle

Clients should not be forced to depend on methods they do not use.

BAD: Fat interface

// BAD: All devices must implement all functions
interface SmartDevice {
  print(): void
  scan(): void
  fax(): void
  copyDocument(): void
}

// A printer cannot scan or fax
class SimplePrinter implements SmartDevice {
  print() {
    /* OK */
  }
  scan() {
    throw new Error('Not supported')
  } // Forced implementation
  fax() {
    throw new Error('Not supported')
  } // Forced implementation
  copyDocument() {
    throw new Error('Not supported')
  }
}

GOOD: Segregated interfaces

// GOOD: Role-based segregated interfaces
interface Printer {
  print(): void
}

interface Scanner {
  scan(): void
}

interface FaxMachine {
  fax(): void
}

// Implement only the interfaces you need
class SimplePrinter implements Printer {
  print() {
    /* handle printing */
  }
}

class AllInOnePrinter implements Printer, Scanner, FaxMachine {
  print() {
    /* print */
  }
  scan() {
    /* scan */
  }
  fax() {
    /* fax */
  }
}

2.5 D — Dependency Inversion Principle

High-level modules should not depend on low-level modules. Both should depend on abstractions.

This is the core principle behind every architecture pattern.

BAD: High-level directly depends on low-level

// BAD: Business logic depends on concrete implementations
class OrderService {
  private mysqlDb = new MySQLDatabase() // Depends on concrete class
  private smtpMail = new SmtpEmailClient() // Depends on concrete class

  createOrder(order: Order) {
    this.mysqlDb.insert('orders', order)
    this.smtpMail.send(order.userEmail, 'Order confirmed')
  }
}
// Switching from MySQL to PostgreSQL requires modifying OrderService!

GOOD: Depend on abstractions

// GOOD: Depend on interfaces (abstractions)
interface OrderRepository {
  save(order: Order): Promise<Order>
}

interface NotificationService {
  notify(userId: string, message: string): Promise<void>
}

class OrderService {
  constructor(
    private repository: OrderRepository, // Depends on abstraction
    private notification: NotificationService // Depends on abstraction
  ) {}

  async createOrder(order: Order): Promise<Order> {
    const saved = await this.repository.save(order)
    await this.notification.notify(order.userId, 'Order confirmed')
    return saved
  }
}

// Implementations live in the infrastructure layer
class PostgresOrderRepository implements OrderRepository {
  async save(order: Order): Promise<Order> {
    return this.prisma.order.create({ data: order })
  }
}

class SlackNotificationService implements NotificationService {
  async notify(userId: string, message: string): Promise<void> {
    await this.slackClient.postMessage({ channel: userId, text: message })
  }
}

3. Layered Architecture

3.1 Basic Structure

Layered Architecture is the most traditional and widely used pattern. Clear separation of responsibilities between layers is the key.

+-----------------------------+
|     Presentation Layer      |  <- Controller, View
+-----------------------------+
|     Application Layer       |  <- Service, DTO
+-----------------------------+
|       Domain Layer          |  <- Entity, Business Logic
+-----------------------------+
|    Infrastructure Layer     |  <- Repository, External API
+-----------------------------+

Dependency direction: Top -> Bottom (Presentation -> Infrastructure)

3.2 Spring Boot Project Structure

src/main/java/com/example/shop/
  controller/
    OrderController.java
  service/
    OrderService.java
  domain/
    Order.java
    OrderItem.java
  repository/
    OrderRepository.java
    JpaOrderRepository.java
  dto/
    CreateOrderRequest.java
    OrderResponse.java

3.3 Implementation Example

// Presentation Layer
@RestController
@RequestMapping("/api/orders")
public class OrderController {

    private final OrderService orderService;

    @PostMapping
    public ResponseEntity<OrderResponse> createOrder(
            @RequestBody CreateOrderRequest request) {
        OrderResponse response = orderService.createOrder(request);
        return ResponseEntity.status(201).body(response);
    }
}

// Application Layer
@Service
@Transactional
public class OrderService {

    private final OrderRepository orderRepository;
    private final ProductRepository productRepository;

    public OrderResponse createOrder(CreateOrderRequest request) {
        Product product = productRepository.findById(request.getProductId())
            .orElseThrow(() -> new NotFoundException("Product not found"));

        Order order = Order.create(
            request.getUserId(),
            product,
            request.getQuantity()
        );

        Order saved = orderRepository.save(order);
        return OrderResponse.from(saved);
    }
}

// Domain Layer
@Entity
public class Order {
    @Id @GeneratedValue
    private Long id;
    private String userId;
    private BigDecimal totalAmount;
    private OrderStatus status;

    public static Order create(String userId, Product product, int quantity) {
        Order order = new Order();
        order.userId = userId;
        order.totalAmount = product.getPrice().multiply(BigDecimal.valueOf(quantity));
        order.status = OrderStatus.CREATED;
        return order;
    }

    public void cancel() {
        if (this.status != OrderStatus.CREATED) {
            throw new IllegalStateException("Only CREATED orders can be cancelled");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

3.4 Pros and Cons

Pros:

  • Easy to understand — familiar to most developers
  • Fast development — suitable for simple CRUD apps
  • Separation of concerns — provides basic layer separation

Cons:

  • Domain logic tends to scatter across the service layer (Anemic Domain Model)
  • Top-down dependency means DB changes have wide impact
  • Tests require mocking infrastructure dependencies
  • Difficult to express complex business logic

3.5 When to Use

  • Simple CRUD applications
  • Small teams that need rapid prototyping
  • When business logic is not complex
  • When using a stable technology stack that is unlikely to change

4. Clean Architecture (Uncle Bob)

4.1 Core Concept: Concentric Circles

Clean Architecture, published by Robert C. Martin in 2012, is built around the Dependency Rule: all dependencies must point inward.

+--------------------------------------------------+
|  Frameworks & Drivers (Web, DB, External)        |
|  +------------------------------------------+    |
|  |  Interface Adapters (Controllers, GW)    |    |
|  |  +----------------------------------+    |    |
|  |  |  Application (Use Cases)         |    |    |
|  |  |  +------------------------+      |    |    |
|  |  |  |     Entities           |      |    |    |
|  |  |  |   (Domain Models)      |      |    |    |
|  |  |  +------------------------+      |    |    |
|  |  +----------------------------------+    |    |
|  +------------------------------------------+    |
+--------------------------------------------------+

Dependency direction: Outside -> Inside (Frameworks -> Entities)

4.2 Role of Each Layer

Entities: Encapsulate business rules. The most stable, least-changing core logic.

// Entities - No dependency on any framework
class Order {
  private items: OrderItem[] = []
  private status: OrderStatus = 'CREATED'

  addItem(product: Product, quantity: number): void {
    if (quantity <= 0) throw new Error('Quantity must be positive')
    if (this.status !== 'CREATED') throw new Error('Cannot modify confirmed order')
    this.items.push(new OrderItem(product, quantity))
  }

  get totalAmount(): Money {
    return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
  }

  confirm(): void {
    if (this.items.length === 0) throw new Error('Cannot confirm empty order')
    this.status = 'CONFIRMED'
  }
}

Use Cases: Application-specific business rules. Orchestrate entities to implement scenarios.

// Use Cases
interface OrderRepository {
  save(order: Order): Promise<Order>
  findById(id: string): Promise<Order | null>
}

interface PaymentGateway {
  charge(amount: Money, paymentMethod: PaymentMethod): Promise<PaymentResult>
}

class CreateOrderUseCase {
  constructor(
    private orderRepo: OrderRepository,
    private paymentGateway: PaymentGateway,
    private eventPublisher: DomainEventPublisher
  ) {}

  async execute(input: CreateOrderInput): Promise<CreateOrderOutput> {
    const order = new Order(input.userId)

    for (const item of input.items) {
      order.addItem(item.product, item.quantity)
    }

    order.confirm()

    const paymentResult = await this.paymentGateway.charge(order.totalAmount, input.paymentMethod)

    if (!paymentResult.success) {
      throw new PaymentFailedError(paymentResult.reason)
    }

    const saved = await this.orderRepo.save(order)
    await this.eventPublisher.publish(new OrderCreatedEvent(saved.id))

    return { orderId: saved.id, totalAmount: order.totalAmount.toString() }
  }
}

Interface Adapters: Connect Use Cases to the external world. Controllers, Presenters, Gateways.

// Interface Adapter - Controller
class OrderController {
  constructor(private createOrderUseCase: CreateOrderUseCase) {}

  async handleCreateOrder(req: HttpRequest): Promise<HttpResponse> {
    try {
      const input = this.mapToInput(req.body)
      const output = await this.createOrderUseCase.execute(input)
      return { status: 201, body: output }
    } catch (error) {
      if (error instanceof PaymentFailedError) {
        return { status: 402, body: { error: error.message } }
      }
      return { status: 500, body: { error: 'Internal server error' } }
    }
  }

  private mapToInput(body: any): CreateOrderInput {
    return {
      userId: body.userId,
      items: body.items,
      paymentMethod: body.paymentMethod,
    }
  }
}

4.3 Spring Boot Project Structure

src/main/java/com/example/shop/
  domain/                    # Entities
    model/
      Order.java
      OrderItem.java
      Money.java
    event/
      OrderCreatedEvent.java
  application/               # Use Cases
    port/
      in/
        CreateOrderUseCase.java
      out/
        OrderRepository.java
        PaymentGateway.java
    service/
      OrderApplicationService.java
  adapter/                   # Interface Adapters + Frameworks
    in/
      web/
        OrderController.java
        CreateOrderRequest.java
    out/
      persistence/
        JpaOrderRepository.java
        OrderJpaEntity.java
      payment/
        StripePaymentGateway.java
  config/
    BeanConfiguration.java

4.4 NestJS Project Structure

src/
  domain/
    entities/
      order.entity.ts
      order-item.entity.ts
      money.value-object.ts
    events/
      order-created.event.ts
  application/
    ports/
      order.repository.port.ts
      payment.gateway.port.ts
    use-cases/
      create-order.use-case.ts
      cancel-order.use-case.ts
  infrastructure/
    adapters/
      in/
        http/
          order.controller.ts
          dto/
            create-order.dto.ts
      out/
        persistence/
          prisma-order.repository.ts
        payment/
          stripe-payment.gateway.ts
    config/
      module.config.ts

4.5 The Power of the Dependency Rule

The heart of Clean Architecture is the Dependency Rule. Inner circles know nothing about outer circles.

// Domain layer - ZERO external dependencies
// If you look at imports in Order.ts:
// - No express
// - No prisma
// - No typeorm
// Only pure TypeScript/Java code

class Order {
  // Only pure business logic
  // Does not care if DB is MySQL or MongoDB
  // Does not care if web framework is Express or Fastify
}

Thanks to this rule, replacing frameworks or databases does not affect your core business logic.


5. Hexagonal Architecture (Ports and Adapters)

5.1 Alistair Cockburn's Original Concept

Hexagonal Architecture was proposed by Alistair Cockburn in 2005. Its official name is "Ports and Adapters." The hexagonal shape represents that the number of ports is flexible — it does not literally mean six.

              +----------+
     HTTP --> |          | --> PostgreSQL
              |   Port   |
     CLI -->  |    &     | --> Redis
              |  Domain  |
   gRPC -->  |   Core   | --> Kafka
              |          |
    Test -->  |          | --> Mock DB
              +----------+

  [Driving Adapters]  [Core]  [Driven Adapters]
    (Input side)    (Business)  (Output side)

5.2 Ports and Adapters Concepts

Port: An interface for business logic to communicate with the outside world. Ports come in two types:

  • Driving Port (Inbound Port): Interface for the outside world to call the application
  • Driven Port (Outbound Port): Interface for the application to call external systems

Adapter: A concrete implementation of a port.

  • Driving Adapter (Inbound Adapter): REST Controller, CLI, gRPC Handler
  • Driven Adapter (Outbound Adapter): JPA Repository, HTTP Client, Message Publisher

5.3 Full Implementation Example

// ===== PORTS (Interface definitions) =====

// Driving Port (Inbound) - External calls the application
interface CreateOrderPort {
  createOrder(command: CreateOrderCommand): Promise<OrderId>
}

interface CancelOrderPort {
  cancelOrder(orderId: string): Promise<void>
}

// Driven Port (Outbound) - Application calls external
interface LoadOrderPort {
  findById(id: string): Promise<Order | null>
}

interface SaveOrderPort {
  save(order: Order): Promise<Order>
}

interface SendNotificationPort {
  sendOrderConfirmation(order: Order): Promise<void>
}

// ===== DOMAIN CORE (Core business logic) =====

class Order {
  private readonly id: string
  private items: OrderItem[] = []
  private status: OrderStatus = 'PENDING'

  constructor(
    id: string,
    private readonly customerId: string
  ) {
    this.id = id
  }

  addItem(productId: string, price: Money, quantity: number): void {
    this.ensureModifiable()
    this.items.push(new OrderItem(productId, price, quantity))
  }

  confirm(): void {
    if (this.items.length === 0) {
      throw new DomainError('Cannot confirm empty order')
    }
    this.status = 'CONFIRMED'
  }

  cancel(): void {
    if (this.status === 'SHIPPED') {
      throw new DomainError('Cannot cancel shipped order')
    }
    this.status = 'CANCELLED'
  }

  private ensureModifiable(): void {
    if (this.status !== 'PENDING') {
      throw new DomainError('Order is not modifiable')
    }
  }

  get totalAmount(): Money {
    return this.items.reduce((sum, item) => sum.add(item.subtotal), Money.zero('KRW'))
  }
}

// ===== APPLICATION SERVICE (Use case implementation) =====

class OrderService implements CreateOrderPort, CancelOrderPort {
  constructor(
    private readonly loadOrder: LoadOrderPort,
    private readonly saveOrder: SaveOrderPort,
    private readonly notification: SendNotificationPort
  ) {}

  async createOrder(command: CreateOrderCommand): Promise<OrderId> {
    const order = new Order(generateId(), command.customerId)

    for (const item of command.items) {
      order.addItem(item.productId, new Money(item.price, 'KRW'), item.quantity)
    }

    order.confirm()
    const saved = await this.saveOrder.save(order)
    await this.notification.sendOrderConfirmation(saved)

    return saved.id
  }

  async cancelOrder(orderId: string): Promise<void> {
    const order = await this.loadOrder.findById(orderId)
    if (!order) throw new NotFoundError('Order not found')

    order.cancel()
    await this.saveOrder.save(order)
  }
}

// ===== ADAPTERS (Concrete implementations) =====

// Driving Adapter - REST Controller
class OrderRestController {
  constructor(private createOrderPort: CreateOrderPort) {}

  async handlePost(req: Request, res: Response): Promise<void> {
    const command: CreateOrderCommand = {
      customerId: req.body.customerId,
      items: req.body.items,
    }
    const orderId = await this.createOrderPort.createOrder(command)
    res.status(201).json({ orderId })
  }
}

// Driven Adapter - Database
class PostgresOrderAdapter implements LoadOrderPort, SaveOrderPort {
  constructor(private prisma: PrismaClient) {}

  async findById(id: string): Promise<Order | null> {
    const data = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    })
    return data ? this.toDomain(data) : null
  }

  async save(order: Order): Promise<Order> {
    await this.prisma.order.upsert({
      where: { id: order.id },
      create: this.toPersistence(order),
      update: this.toPersistence(order),
    })
    return order
  }

  private toDomain(data: PrismaOrder): Order {
    /* mapping */
  }
  private toPersistence(order: Order): PrismaOrderData {
    /* mapping */
  }
}

// Driven Adapter - Notification
class EmailNotificationAdapter implements SendNotificationPort {
  constructor(private mailer: MailClient) {}

  async sendOrderConfirmation(order: Order): Promise<void> {
    await this.mailer.send({
      to: order.customerEmail,
      subject: 'Order Confirmed',
      body: `Your order #${order.id} has been confirmed.`,
    })
  }
}

5.4 Clean Architecture vs Hexagonal Architecture

AspectClean ArchitectureHexagonal Architecture
Proposed byRobert C. Martin (2012)Alistair Cockburn (2005)
VisualizationConcentric CirclesHexagon
Core ideaDependencies point inwardSeparation via Ports and Adapters
Number of layers4 (Entities, Use Cases, Adapters, Frameworks)3 (Adapters, Ports, Domain)
EmphasisDependency RuleInput/Output symmetry
TestingEasy Use Case unit testingEasy adapter swapping for tests
EssenceEssentially the same principle, different expressionEssentially the same principle, different expression

Both architectures are based on the same fundamental principle of Dependency Inversion. In practice, they are often used interchangeably.


6. DDD (Domain-Driven Design)

6.1 What Is DDD

DDD (Domain-Driven Design) is a methodology introduced by Eric Evans in his 2003 book. It places the complex business domain at the center of the software model.

DDD is divided into Strategic DDD (big-picture design) and Tactical DDD (code-level design).

6.2 Strategic DDD — Big Picture Design

Ubiquitous Language

Developers and business experts use the same terminology. Class names and method names in code must match business terms.

Business: "A customer creates an order, and when payment is approved, the order is confirmed"
Code:     customer.createOrder() -> payment.approve() -> order.confirm()

Business: "An order cannot be cancelled once shipping has started"
Code:     order.cancel() -> if (status === 'SHIPPED') throw Error

Bounded Context

Even within a single system, the same term can have different meanings. A Bounded Context explicitly defines the boundary where a particular model is valid.

+------------------+  +------------------+  +------------------+
|  Order Context   |  | Payment Context  |  | Shipping Context |
|                  |  |                  |  |                  |
|  Order           |  |  Payment         |  |  Shipment        |
|  OrderItem       |  |  Transaction     |  |  Package         |
|  Customer (id,   |  |  Customer (id,   |  |  Customer (id,   |
|   name, email)   |  |   paymentMethod) |  |   address)       |
|                  |  |                  |  |                  |
|  "Customer" =    |  |  "Customer" =    |  |  "Customer" =    |
|  the orderer     |  |  the payer       |  |  the recipient   |
+--------+---------+  +--------+---------+  +--------+---------+
         |                     |                     |
         +------ Context Map (Context Mapping) ------+

Context Mapping Patterns

Patterns that define relationships between Bounded Contexts:

  • Shared Kernel: Two contexts share a common model
  • Customer-Supplier: One side is the supplier, the other is the consumer
  • Anti-Corruption Layer (ACL): A translation layer to prevent external models from corrupting internal ones
  • Published Language: Integration through a well-documented shared language
  • Separate Ways: Each context operates independently without integration

6.3 Tactical DDD — Code-Level Design

Entity

An object with a unique identity that persists throughout its lifecycle.

public class Order {
    private final OrderId id;  // Unique identifier
    private OrderStatus status;
    private List<OrderItem> items;
    private Money totalAmount;

    // Business logic lives inside the entity (Rich Domain Model)
    public void addItem(Product product, int quantity) {
        validateModifiable();
        OrderItem item = new OrderItem(product, quantity);
        this.items.add(item);
        recalculateTotal();
    }

    public void confirm() {
        if (items.isEmpty()) {
            throw new OrderEmptyException("Cannot confirm empty order");
        }
        this.status = OrderStatus.CONFIRMED;
        registerEvent(new OrderConfirmedEvent(this.id));
    }

    // equals/hashCode based on id
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Order)) return false;
        return id.equals(((Order) o).id);
    }
}

Value Object

No identity. Equality is determined by attribute values. Immutable.

public record Money(BigDecimal amount, Currency currency) {

    public Money {
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
        Objects.requireNonNull(currency);
    }

    public Money add(Money other) {
        validateSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }

    public static Money zero(Currency currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    private void validateSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException(
                "Cannot operate on different currencies"
            );
        }
    }
}

// Usage example
Money price = new Money(BigDecimal.valueOf(15000), Currency.KRW);
Money total = price.multiply(3);  // 45000 KRW

Aggregate

A cluster of related entities and value objects. Accessible only through the Aggregate Root.

// Order is the Aggregate Root
// OrderItem is only accessible through Order
public class Order {  // Aggregate Root
    private final OrderId id;
    private final CustomerId customerId;
    private List<OrderItem> items = new ArrayList<>();  // Internal entity
    private ShippingAddress address;  // Value Object
    private Money totalAmount;        // Value Object

    // External code cannot create/modify OrderItem directly
    // Must go through Order
    public void addItem(ProductId productId, Money price, int qty) {
        OrderItem item = new OrderItem(productId, price, qty);
        this.items.add(item);
        recalculateTotal();
    }

    public void removeItem(ProductId productId) {
        this.items.removeIf(item -> item.getProductId().equals(productId));
        recalculateTotal();
    }
}

Aggregate Design Rules:

  1. Outside the Aggregate, only reference the Root Entity
  2. Inter-Aggregate references use IDs only (no direct object references)
  3. Modify only one Aggregate per transaction
  4. Keep Aggregates small (1 Root + a few internal objects)

Domain Event

An object representing an important occurrence in the domain.

public record OrderConfirmedEvent(
    OrderId orderId,
    CustomerId customerId,
    Money totalAmount,
    Instant occurredAt
) implements DomainEvent {

    public OrderConfirmedEvent(OrderId orderId, CustomerId customerId, Money totalAmount) {
        this(orderId, customerId, totalAmount, Instant.now());
    }
}

// Registering events in the Aggregate Root
public class Order extends AbstractAggregateRoot<Order> {

    public void confirm() {
        this.status = OrderStatus.CONFIRMED;
        registerEvent(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
    }
}

// Event handler
@Component
public class OrderConfirmedEventHandler {

    @EventListener
    public void handleOrderConfirmed(OrderConfirmedEvent event) {
        // Trigger payment processing
        // Trigger inventory deduction
        // Send confirmation email
    }
}

Repository

Handles persistence for Aggregates. Interface in the Domain layer, implementation in Infrastructure.

// Interface defined in the Domain layer
public interface OrderRepository {
    Order findById(OrderId id);
    OrderId save(Order order);
    void delete(Order order);
    List<Order> findByCustomerId(CustomerId customerId);
}

// Implementation in the Infrastructure layer
@Repository
public class JpaOrderRepository implements OrderRepository {

    private final SpringDataOrderRepository springDataRepo;
    private final OrderMapper mapper;

    @Override
    public Order findById(OrderId id) {
        return springDataRepo.findById(id.getValue())
            .map(mapper::toDomain)
            .orElseThrow(() -> new OrderNotFoundException(id));
    }

    @Override
    public OrderId save(Order order) {
        OrderJpaEntity entity = mapper.toJpaEntity(order);
        OrderJpaEntity saved = springDataRepo.save(entity);
        return new OrderId(saved.getId());
    }
}

6.4 Event Storming

Event Storming is a workshop technique in DDD for exploring the domain and discovering Bounded Contexts.

1. Domain Events (orange sticky notes) - Written in past tense
   "Order was created"  "Payment was approved"  "Product was shipped"

2. Commands (blue sticky notes) - Actions that trigger events
   "Create order"  "Approve payment"  "Start shipping"

3. Aggregates (yellow sticky notes) - Entities that handle commands
   "Order"  "Payment"  "Shipment"

4. Discover Bounded Contexts - Group related elements
   Order Context | Payment Context | Shipping Context

6.5 DDD + Hexagonal = A Powerful Combination

Placing DDD's tactical patterns within a Hexagonal Architecture creates the most powerful combination.

src/
  order/                          # Bounded Context
    domain/                       # Hexagonal Core
      model/
        Order.java                # Aggregate Root (Entity)
        OrderItem.java            # Entity
        Money.java                # Value Object
        OrderStatus.java          # Value Object (Enum)
      event/
        OrderConfirmedEvent.java  # Domain Event
      service/
        OrderDomainService.java   # Domain Service
    application/                  # Hexagonal Ports + Use Cases
      port/
        in/
          CreateOrderUseCase.java    # Driving Port
          CancelOrderUseCase.java
        out/
          LoadOrderPort.java         # Driven Port
          SaveOrderPort.java
          PaymentPort.java
      service/
        OrderApplicationService.java # Use Case Implementation
    infrastructure/               # Hexagonal Adapters
      adapter/
        in/
          web/
            OrderController.java     # Driving Adapter
          messaging/
            OrderEventListener.java
        out/
          persistence/
            JpaOrderRepository.java  # Driven Adapter
            OrderJpaEntity.java
          payment/
            StripePaymentAdapter.java

6.6 When NOT to Use DDD

DDD is powerful but not suitable for every project.

Do not use when:

  • Simple CRUD application
  • Data pipelines with little business logic
  • Prototypes or MVPs
  • The team lacks understanding of DDD
  • Collaboration with domain experts is not possible

DDD Complexity Decision Guide:

Business Logic Complexity
  ^
  |  DDD + Hexagonal
  |  +--------------+
  |  |              |
  |  |  Sweet Spot  |
  |  |              |
  |  +--------------+
  |     Clean Architecture
  |  +--------------+
  |  |              |
  |  +--------------+
  |     Layered
  |  +--------------+
  |  +--------------+
  +-----------------------> Project Size / Team Size

7. Architecture Comparison Summary

7.1 Comparison Table

AspectLayeredCleanHexagonalDDD
ComplexityLowMediumMedium-HighHigh
Learning CurveLowMediumMediumHigh
Best ForSimple CRUDMedium complexityComplex domains, many integrationsComplex business logic
Key BenefitEasy to understandFramework independenceSwappable adaptersBusiness alignment
TestabilityAverageHighHighVery High
Team SizeSmallMediumMedium-LargeLarge
DB Swap EaseDifficultEasyVery EasyVery Easy

7.2 Selection Flowchart

Project Start
  |
  +-- Is business logic simple? --YES--> Layered Architecture
  |
  NO
  |
  +-- Many external integrations? --YES--> Hexagonal Architecture
  |
  NO
  |
  +-- Domain expert collaboration possible? --YES--+
  |                                                |
  NO                                               v
  |                              Is business logic very complex?
  |                                  |           |
  |                                 YES          NO
  |                                  |           |
  |                                  v           v
  |                         DDD + Hexagonal   Clean Architecture
  |
  +--> Clean Architecture (safe default choice)

7.3 Practical Tips

  1. Incremental adoption: Do not apply DDD to everything from the start. Begin with the most complex module
  2. Mix and match: Different modules in one system can use different architectures (DDD for core, Layered for the rest)
  3. Consider team capability: Choose architecture that matches your team's experience and understanding
  4. YAGNI principle: Do not create abstractions you do not need yet

8. Hands-On Project: E-Commerce Order System

8.1 Domain Model Design

A full implementation of an e-commerce order system using Clean/Hexagonal + DDD.

Money (Value Object)

public record Money(BigDecimal amount, String currency) {

    public Money {
        Objects.requireNonNull(amount, "Amount must not be null");
        Objects.requireNonNull(currency, "Currency must not be null");
        if (amount.compareTo(BigDecimal.ZERO) < 0) {
            throw new IllegalArgumentException("Amount cannot be negative");
        }
    }

    public static Money of(long amount, String currency) {
        return new Money(BigDecimal.valueOf(amount), currency);
    }

    public static Money zero(String currency) {
        return new Money(BigDecimal.ZERO, currency);
    }

    public Money add(Money other) {
        assertSameCurrency(other);
        return new Money(this.amount.add(other.amount), this.currency);
    }

    public Money multiply(int quantity) {
        return new Money(this.amount.multiply(BigDecimal.valueOf(quantity)), this.currency);
    }

    public boolean isGreaterThan(Money other) {
        assertSameCurrency(other);
        return this.amount.compareTo(other.amount) > 0;
    }

    private void assertSameCurrency(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                "Currency mismatch: " + this.currency + " vs " + other.currency
            );
        }
    }
}

Order (Aggregate Root)

public class Order {
    private final String id;
    private final String customerId;
    private final List<OrderItem> items = new ArrayList<>();
    private OrderStatus status;
    private Money totalAmount;
    private final Instant createdAt;
    private final List<DomainEvent> domainEvents = new ArrayList<>();

    public Order(String id, String customerId) {
        this.id = id;
        this.customerId = customerId;
        this.status = OrderStatus.PENDING;
        this.totalAmount = Money.zero("KRW");
        this.createdAt = Instant.now();
    }

    public void addItem(String productId, String productName, Money price, int quantity) {
        ensureModifiable();
        this.items.add(new OrderItem(productId, productName, price, quantity));
        recalculateTotal();
    }

    public void confirm() {
        if (items.isEmpty()) {
            throw new OrderDomainException("Cannot confirm empty order");
        }
        if (status != OrderStatus.PENDING) {
            throw new OrderDomainException("Only pending orders can be confirmed");
        }
        this.status = OrderStatus.CONFIRMED;
        domainEvents.add(new OrderConfirmedEvent(this.id, this.customerId, this.totalAmount));
    }

    public void cancel() {
        if (status == OrderStatus.SHIPPED || status == OrderStatus.DELIVERED) {
            throw new OrderDomainException("Cannot cancel order in status: " + status);
        }
        this.status = OrderStatus.CANCELLED;
        domainEvents.add(new OrderCancelledEvent(this.id, this.customerId));
    }

    public void markAsShipped(String trackingNumber) {
        if (status != OrderStatus.CONFIRMED) {
            throw new OrderDomainException("Only confirmed orders can be shipped");
        }
        this.status = OrderStatus.SHIPPED;
        domainEvents.add(new OrderShippedEvent(this.id, trackingNumber));
    }

    private void ensureModifiable() {
        if (status != OrderStatus.PENDING) {
            throw new OrderDomainException("Order is not modifiable");
        }
    }

    private void recalculateTotal() {
        this.totalAmount = items.stream()
            .map(OrderItem::getSubtotal)
            .reduce(Money.zero("KRW"), Money::add);
    }

    public List<DomainEvent> getDomainEvents() {
        return Collections.unmodifiableList(domainEvents);
    }

    public void clearDomainEvents() {
        domainEvents.clear();
    }
}

8.2 Application Layer (Use Cases)

// Driving Port
public interface CreateOrderUseCase {
    CreateOrderResult execute(CreateOrderCommand command);
}

// Driven Ports
public interface OrderRepository {
    Optional<Order> findById(String id);
    Order save(Order order);
}

public interface EventPublisher {
    void publish(List<DomainEvent> events);
}

// Use Case Implementation
@Service
@Transactional
public class CreateOrderService implements CreateOrderUseCase {

    private final OrderRepository orderRepository;
    private final EventPublisher eventPublisher;
    private final IdGenerator idGenerator;

    @Override
    public CreateOrderResult execute(CreateOrderCommand command) {
        Order order = new Order(idGenerator.generate(), command.customerId());

        for (OrderItemCommand item : command.items()) {
            order.addItem(
                item.productId(),
                item.productName(),
                Money.of(item.price(), "KRW"),
                item.quantity()
            );
        }

        order.confirm();
        Order saved = orderRepository.save(order);
        eventPublisher.publish(saved.getDomainEvents());
        saved.clearDomainEvents();

        return new CreateOrderResult(saved.getId(), saved.getTotalAmount().toString());
    }
}

8.3 Infrastructure Layer (Adapters)

// Driven Adapter - JPA Repository
@Repository
public class JpaOrderRepositoryAdapter implements OrderRepository {

    private final SpringDataOrderRepository jpaRepo;
    private final OrderPersistenceMapper mapper;

    @Override
    public Optional<Order> findById(String id) {
        return jpaRepo.findById(id).map(mapper::toDomain);
    }

    @Override
    public Order save(Order order) {
        OrderJpaEntity entity = mapper.toJpaEntity(order);
        OrderJpaEntity saved = jpaRepo.save(entity);
        return mapper.toDomain(saved);
    }
}

// Driven Adapter - Kafka Event Publisher
@Component
public class KafkaEventPublisher implements EventPublisher {

    private final KafkaTemplate<String, String> kafkaTemplate;
    private final ObjectMapper objectMapper;

    @Override
    public void publish(List<DomainEvent> events) {
        for (DomainEvent event : events) {
            String topic = resolveTopic(event);
            String payload = serialize(event);
            kafkaTemplate.send(topic, event.getAggregateId(), payload);
        }
    }

    private String resolveTopic(DomainEvent event) {
        if (event instanceof OrderConfirmedEvent) return "order.confirmed";
        if (event instanceof OrderCancelledEvent) return "order.cancelled";
        if (event instanceof OrderShippedEvent) return "order.shipped";
        return "order.unknown";
    }
}

// Driving Adapter - REST Controller
@RestController
@RequestMapping("/api/v1/orders")
public class OrderController {

    private final CreateOrderUseCase createOrderUseCase;
    private final CancelOrderUseCase cancelOrderUseCase;

    @PostMapping
    public ResponseEntity<CreateOrderResponse> createOrder(
            @Valid @RequestBody CreateOrderRequest request) {
        CreateOrderCommand command = request.toCommand();
        CreateOrderResult result = createOrderUseCase.execute(command);
        return ResponseEntity.status(201).body(CreateOrderResponse.from(result));
    }

    @DeleteMapping("/{orderId}")
    public ResponseEntity<Void> cancelOrder(@PathVariable String orderId) {
        cancelOrderUseCase.execute(orderId);
        return ResponseEntity.noContent().build();
    }
}

9. Testing Strategy

9.1 Domain Logic Unit Tests

Domain logic can be tested purely without external dependencies. This is the greatest advantage of Clean/Hexagonal Architecture.

class OrderTest {

    @Test
    void shouldCalculateTotalAmount() {
        Order order = new Order("order-1", "customer-1");
        order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 2);
        order.addItem("prod-2", "Mouse", Money.of(30000, "KRW"), 1);

        assertEquals(Money.of(130000, "KRW"), order.getTotalAmount());
    }

    @Test
    void shouldNotConfirmEmptyOrder() {
        Order order = new Order("order-1", "customer-1");

        assertThrows(OrderDomainException.class, () -> order.confirm());
    }

    @Test
    void shouldNotCancelShippedOrder() {
        Order order = createConfirmedOrder();
        order.markAsShipped("TRACK-123");

        assertThrows(OrderDomainException.class, () -> order.cancel());
    }

    @Test
    void shouldRegisterDomainEventOnConfirm() {
        Order order = new Order("order-1", "customer-1");
        order.addItem("prod-1", "Keyboard", Money.of(50000, "KRW"), 1);
        order.confirm();

        List<DomainEvent> events = order.getDomainEvents();
        assertEquals(1, events.size());
        assertInstanceOf(OrderConfirmedEvent.class, events.get(0));
    }
}

class MoneyTest {

    @Test
    void shouldAddSameCurrency() {
        Money a = Money.of(1000, "KRW");
        Money b = Money.of(2000, "KRW");

        assertEquals(Money.of(3000, "KRW"), a.add(b));
    }

    @Test
    void shouldRejectDifferentCurrency() {
        Money krw = Money.of(1000, "KRW");
        Money usd = Money.of(1, "USD");

        assertThrows(IllegalArgumentException.class, () -> krw.add(usd));
    }
}

9.2 Use Case Integration Tests

class CreateOrderServiceTest {

    private CreateOrderService sut;
    private OrderRepository fakeRepository;
    private EventPublisher fakePublisher;

    @BeforeEach
    void setUp() {
        fakeRepository = new InMemoryOrderRepository();
        fakePublisher = new FakeEventPublisher();
        sut = new CreateOrderService(fakeRepository, fakePublisher, new UuidIdGenerator());
    }

    @Test
    void shouldCreateAndConfirmOrder() {
        CreateOrderCommand command = new CreateOrderCommand(
            "customer-1",
            List.of(new OrderItemCommand("prod-1", "Keyboard", 50000, 2))
        );

        CreateOrderResult result = sut.execute(command);

        assertNotNull(result.orderId());
        Order saved = fakeRepository.findById(result.orderId()).orElseThrow();
        assertEquals(OrderStatus.CONFIRMED, saved.getStatus());
    }
}

// In-Memory Repository for testing
class InMemoryOrderRepository implements OrderRepository {
    private final Map<String, Order> store = new HashMap<>();

    @Override
    public Optional<Order> findById(String id) {
        return Optional.ofNullable(store.get(id));
    }

    @Override
    public Order save(Order order) {
        store.put(order.getId(), order);
        return order;
    }
}

9.3 Architecture Tests with ArchUnit

ArchUnit lets you automatically verify architecture rules.

class ArchitectureTest {

    private final JavaClasses classes = new ClassFileImporter()
        .importPackages("com.example.shop");

    @Test
    void domainShouldNotDependOnInfrastructure() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..infrastructure..")
            .check(classes);
    }

    @Test
    void domainShouldNotDependOnApplication() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().dependOnClassesThat()
            .resideInAPackage("..application..")
            .check(classes);
    }

    @Test
    void domainShouldNotUseSpringAnnotations() {
        noClasses()
            .that().resideInAPackage("..domain..")
            .should().beAnnotatedWith("org.springframework.stereotype.Service")
            .orShould().beAnnotatedWith("org.springframework.stereotype.Component")
            .check(classes);
    }

    @Test
    void useCasesShouldOnlyBeAccessedByAdapters() {
        classes()
            .that().resideInAPackage("..application.service..")
            .should().onlyBeAccessed().byClassesThat()
            .resideInAnyPackage("..adapter..", "..config..", "..application..")
            .check(classes);
    }
}

9.4 Test Pyramid

          /  E2E Tests  \          <- Few, Slow, Expensive
         / (Cypress, etc) \
        /------------------\
       / Integration Tests  \      <- Medium
      /  (Adapter Tests)     \
     /------------------------\
    /     Unit Tests           \   <- Many, Fast, Cheap
   /  (Domain + Use Case)      \
  /-----------------------------\

In Clean/Hexagonal Architecture, domain logic unit tests make up the majority of all tests. These tests have no external dependencies, making them extremely fast and reliable.


10. Anti-Patterns and Pitfalls

10.1 Over-Engineering

The most common mistake. Applying DDD + Hexagonal to a simple CRUD app actually reduces productivity.

// BAD: Changing a user's name requires 7 files?
// Command -> CommandHandler -> UseCase -> DomainService
// -> Repository -> Adapter -> Mapper
// Overkill for a simple update!

// GOOD: Choose architecture matching the complexity
// Simple CRUD = Layered + Service pattern is sufficient

10.2 Anemic Domain Model

Entities with only getters/setters while business logic is scattered across the service layer. An anti-pattern named by Martin Fowler.

// BAD: Anemic Domain Model
class Order {
    private String status;
    public String getStatus() { return status; }
    public void setStatus(String status) { this.status = status; }
}

class OrderService {
    void cancelOrder(Order order) {
        // Business logic lives in the service
        if (order.getStatus().equals("SHIPPED")) {
            throw new Exception("Cannot cancel");
        }
        order.setStatus("CANCELLED");
    }
}

// GOOD: Rich Domain Model
class Order {
    private OrderStatus status;

    public void cancel() {
        // Business logic lives inside the entity
        if (this.status == OrderStatus.SHIPPED) {
            throw new OrderDomainException("Cannot cancel shipped order");
        }
        this.status = OrderStatus.CANCELLED;
    }
}

10.3 God Class

A single class that takes on too many responsibilities. An extreme form of SRP violation.

// BAD: One service handles everything
class OrderGodService {
    void createOrder() { /* ... */ }
    void cancelOrder() { /* ... */ }
    void processPayment() { /* ... */ }
    void sendNotification() { /* ... */ }
    void generateInvoice() { /* ... */ }
    void updateInventory() { /* ... */ }
    void calculateShipping() { /* ... */ }
    // ... thousands of lines
}

// GOOD: Separated by responsibility
class CreateOrderUseCase { /* order creation only */ }
class CancelOrderUseCase { /* order cancellation only */ }
class PaymentService { /* payment only */ }
class NotificationService { /* notifications only */ }

10.4 Leaky Abstraction

Infrastructure details leaking into the domain layer.

// BAD: JPA annotations on domain entities
@Entity
@Table(name = "orders")
class Order {
    @Id @GeneratedValue
    private Long id;

    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    private List<OrderItem> items;
    // JPA infrastructure details leak into the domain
}

// GOOD: Separate domain entities from JPA entities
// domain/Order.java - Pure domain object
class Order {
    private final String id;
    private List<OrderItem> items;
    // No framework dependencies
}

// infrastructure/OrderJpaEntity.java - JPA-specific
@Entity @Table(name = "orders")
class OrderJpaEntity {
    @Id private String id;
    @OneToMany(cascade = CascadeType.ALL)
    private List<OrderItemJpaEntity> items;
}

10.5 Resume-Driven Architecture

Adopting excessive technologies not for project requirements, but to make a resume look impressive.

"Our project needs Microservices + CQRS + Event Sourcing +
Saga Pattern + GraphQL Federation!"

Actual requirement: An internal tool for 5 users

Result: Still not deployed after 6 months

Principle: Choose solutions that match the problem.


Quiz

Test your understanding with these questions.

Q1: What is the core of the Dependency Inversion Principle (DIP) in SOLID?

A: High-level modules (business logic) should not directly depend on low-level modules (DB, external services). Both should depend on abstractions (interfaces). This is the fundamental foundation of Clean Architecture and Hexagonal Architecture.

Q2: Explain the Dependency Rule in Clean Architecture.

A: All source code dependencies must point inward (toward higher levels). Frameworks (outer) -> Adapters -> Use Cases -> Entities (inner). Inner circles know nothing about outer circles. Entities do not know about Use Cases, and Use Cases do not know about Controllers.

Q3: What is the difference between Driving Adapters and Driven Adapters in Hexagonal Architecture?

A: Driving Adapters (Inbound) are adapters that allow the outside world to call into the application (REST Controllers, CLI, gRPC Handlers). Driven Adapters (Outbound) are adapters that allow the application to call external systems (DB Repositories, HTTP Clients, Message Publishers). The symmetry between input and output is the key concept.

Q4: Explain the four Aggregate design rules in DDD.

A:

  1. Outside the Aggregate, only reference the Root Entity
  2. Inter-Aggregate references use IDs only (no direct object references)
  3. Modify only one Aggregate per transaction
  4. Keep Aggregates small (1 Root + a few internal objects)

These rules ensure data consistency and scalability.

Q5: Why is the Anemic Domain Model considered an anti-pattern?

A: The Anemic Domain Model has entities with only getters/setters while business logic is scattered across the service layer. It is an anti-pattern because: (1) business rules are duplicated/scattered across multiple services, making consistency fragile, (2) domain objects degrade to mere data containers, losing the benefits of OOP (encapsulation), and (3) it becomes difficult to assess the blast radius of business rule changes. In a Rich Domain Model, business logic lives inside entities, providing high cohesion and safe changeability.


References

Books

  1. Robert C. Martin, Clean Architecture: A Craftsman's Guide to Software Structure and Design (2017)
  2. Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software (2003)
  3. Vaughn Vernon, Implementing Domain-Driven Design (2013)
  4. Vaughn Vernon, Domain-Driven Design Distilled (2016)
  5. Martin Fowler, Patterns of Enterprise Application Architecture (2002)
  6. Robert C. Martin, Clean Code: A Handbook of Agile Software Craftsmanship (2008)

Articles and Blogs

  1. Alistair Cockburn, Hexagonal Architecturehttps://alistair.cockburn.us/hexagonal-architecture/
  2. Robert C. Martin, The Clean Architecture (2012) — https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
  3. Martin Fowler, AnemicDomainModelhttps://martinfowler.com/bliki/AnemicDomainModel.html
  4. Martin Fowler, BoundedContexthttps://martinfowler.com/bliki/BoundedContext.html

Tools and Frameworks

  1. ArchUnit — https://www.archunit.org/
  2. Spring Modulith — https://spring.io/projects/spring-modulith
  3. NestJS CQRS Module — https://docs.nestjs.com/recipes/cqrs

Talks and Videos

  1. Uncle Bob, Clean Architecture and Design (NDC Conference)
  2. Vaughn Vernon, Strategic Domain-Driven Design (InfoQ)
  3. Alberto Brandolini, Event Storminghttps://www.eventstorming.com/
  4. Netflix Engineering Blog, Domain-Driven Design at Netflix