Skip to content
Published on

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

Authors

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