เมื่อ Software มีความซับซ้อนมากขึ้น การเขียน Code แบบ CRUD ธรรมดาไม่เพียงพออีกต่อไป Business Logic ที่ซับซ้อน กฎเกณฑ์ที่เปลี่ยนแปลงตลอด และระบบที่ต้องขยายตัว ทำให้ต้องมีวิธีออกแบบ Software ที่ดีกว่า Domain-Driven Design (DDD) คือคำตอบ
บทความนี้จะพาเรียนรู้ DDD ตั้งแต่แนวคิดพื้นฐาน Strategic Design ไปจนถึง Tactical Patterns พร้อมตัวอย่าง Code จริงในปี 2026
DDD คืออะไร?
Domain-Driven Design (DDD) คือแนวทางการออกแบบ Software ที่เน้นให้ Code สะท้อนโดเมนธุรกิจ (Business Domain) อย่างแท้จริง นำเสนอครั้งแรกโดย Eric Evans ในหนังสือ "Domain-Driven Design: Tackling Complexity in the Heart of Software" (2003)
แนวคิดหลักของ DDD:
- Software ต้องสะท้อน Business Domain อย่างแม่นยำ
- ใช้ภาษาเดียวกันระหว่าง Developer กับ Domain Expert (Ubiquitous Language)
- แบ่ง System ออกเป็น Bounded Contexts ที่ชัดเจน
- ให้ Domain Logic อยู่ใน Domain Layer ไม่กระจายไปทั่ว
Strategic Design — ภาพใหญ่
Ubiquitous Language
Ubiquitous Language คือภาษากลางที่ทีมทั้งหมดใช้ร่วมกัน ทั้ง Developer, Domain Expert, QA และ PM:
// BAD: ภาษาของ Developer ไม่ตรงกับธุรกิจ
class DataProcessor {
processItem(data: any) {
if (data.flag === 1) {
this.updateRecord(data);
}
}
}
// GOOD: ใช้ Ubiquitous Language
class OrderService {
confirmOrder(order: Order) {
if (order.isPaid()) {
order.confirm();
this.notifyWarehouse(order);
}
}
}
confirmOrder() ไม่ใช่ processData() หรือ updateStatus()
Bounded Context
Bounded Context คือขอบเขตที่ Model มีความหมายชัดเจน ใน Context หนึ่ง "Customer" อาจหมายถึงสิ่งที่ต่างจากอีก Context:
// Sales Context: Customer = ผู้ซื้อ มี Order History
class Customer {
id: CustomerId;
name: string;
orders: Order[];
loyaltyPoints: number;
placeOrder(items: CartItem[]): Order { ... }
}
// Support Context: Customer = ผู้ร้องเรียน มี Ticket History
class Customer {
id: CustomerId;
name: string;
tickets: SupportTicket[];
satisfactionScore: number;
openTicket(issue: string): SupportTicket { ... }
}
// Shipping Context: Customer = ผู้รับพัสดุ มีที่อยู่
class Recipient {
id: CustomerId;
name: string;
shippingAddress: Address;
contactPhone: string;
}
Context Mapping
ความสัมพันธ์ระหว่าง Bounded Contexts:
| Pattern | คำอธิบาย | ตัวอย่าง |
|---|---|---|
| Shared Kernel | แชร์ Model บางส่วนร่วมกัน | ใช้ User ID ร่วมกัน |
| Customer-Supplier | Context หนึ่งให้ข้อมูล อีกอันรับ | Order ส่งข้อมูลให้ Shipping |
| Anti-Corruption Layer | แปลง Model ระหว่าง Context | Integration กับ Legacy System |
| Conformist | ใช้ Model ของอีก Context ตรงๆ | ใช้ API ของ Third Party |
| Open Host Service | เปิด API ให้ Context อื่นใช้ | REST/gRPC Service |
| Published Language | ใช้ภาษากลางในการสื่อสาร | JSON Schema, Protobuf |
Tactical Design — Pattern ในระดับ Code
Entity
Entity คือ Object ที่มี Identity ชัดเจน แม้ Attribute เปลี่ยน Identity ก็ยังเหมือนเดิม:
class Order {
private readonly id: OrderId;
private status: OrderStatus;
private items: OrderItem[];
private totalAmount: Money;
constructor(id: OrderId, customerId: CustomerId) {
this.id = id;
this.status = OrderStatus.CREATED;
this.items = [];
this.totalAmount = Money.zero();
}
// Identity-based equality
equals(other: Order): boolean {
return this.id.equals(other.id);
}
addItem(product: ProductId, quantity: number, price: Money): void {
const item = new OrderItem(product, quantity, price);
this.items.push(item);
this.recalculateTotal();
}
confirm(): void {
if (this.items.length === 0) {
throw new Error("Cannot confirm empty order");
}
this.status = OrderStatus.CONFIRMED;
}
}
Value Object
Value Object คือ Object ที่ไม่มี Identity เปรียบเทียบกันด้วย Value ทั้งหมด:
class Money {
private constructor(
private readonly amount: number,
private readonly currency: string
) {
if (amount < 0) throw new Error("Amount cannot be negative");
}
static of(amount: number, currency: string): Money {
return new Money(amount, currency);
}
static zero(): Money {
return new Money(0, "THB");
}
add(other: Money): Money {
if (this.currency !== other.currency) {
throw new Error("Cannot add different currencies");
}
return new Money(this.amount + other.amount, this.currency);
}
multiply(factor: number): Money {
return new Money(this.amount * factor, this.currency);
}
// Value-based equality
equals(other: Money): boolean {
return this.amount === other.amount
&& this.currency === other.currency;
}
}
class Address {
constructor(
readonly street: string,
readonly city: string,
readonly province: string,
readonly postalCode: string
) {
// Immutable — ไม่มี setter
Object.freeze(this);
}
}
Aggregate และ Aggregate Root
Aggregate คือกลุ่มของ Entity และ Value Object ที่ต้องเปลี่ยนแปลงพร้อมกัน (Consistency Boundary) โดยมี Aggregate Root เป็นจุดเข้าถึงเดียว:
// Order เป็น Aggregate Root
// OrderItem เป็น Entity ภายใน Aggregate
class Order { // <-- Aggregate Root
private id: OrderId;
private customerId: CustomerId;
private items: OrderItem[]; // ภายใน Aggregate
private shippingAddress: Address; // Value Object
private status: OrderStatus;
// ทุก Operation ต้องผ่าน Aggregate Root
addItem(product: ProductId, qty: number, price: Money): void {
// Business Rule: ไม่เกิน 50 items
if (this.items.length >= 50) {
throw new DomainError("Order cannot have more than 50 items");
}
this.items.push(new OrderItem(product, qty, price));
this.recalculateTotal();
}
removeItem(itemId: OrderItemId): void {
this.items = this.items.filter(i => !i.id.equals(itemId));
this.recalculateTotal();
}
// ห้ามเข้าถึง OrderItem ตรงๆ จากภายนอก
getItemCount(): number {
return this.items.length;
}
}
// กฎของ Aggregate:
// 1. อ้างอิงข้าม Aggregate ด้วย ID เท่านั้น (ไม่ใช่ Object Reference)
// 2. หนึ่ง Transaction = หนึ่ง Aggregate
// 3. Aggregate ต้องเล็ก (ไม่ใหญ่เกินไป)
// 4. ใช้ Domain Events สื่อสารระหว่าง Aggregate
Domain Events
Domain Events คือสิ่งที่เกิดขึ้นในโดเมนที่ Domain Expert สนใจ ใช้สื่อสารระหว่าง Aggregate:
// Domain Event
class OrderConfirmed {
readonly occurredAt: Date;
constructor(
readonly orderId: OrderId,
readonly customerId: CustomerId,
readonly totalAmount: Money,
readonly items: ReadonlyArray
) {
this.occurredAt = new Date();
}
}
// Aggregate Root raise event
class Order {
private domainEvents: DomainEvent[] = [];
confirm(): void {
if (this.status !== OrderStatus.CREATED) {
throw new DomainError("Can only confirm created orders");
}
this.status = OrderStatus.CONFIRMED;
// Raise Domain Event
this.domainEvents.push(new OrderConfirmed(
this.id, this.customerId, this.totalAmount,
this.items.map(i => i.toSnapshot())
));
}
pullDomainEvents(): DomainEvent[] {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
// Event Handler (ใน Context อื่น)
class OrderConfirmedHandler {
async handle(event: OrderConfirmed): Promise {
// สร้าง Shipment ใน Shipping Context
await this.shippingService.createShipment(
event.orderId, event.items
);
// ส่ง Email ยืนยัน
await this.emailService.sendConfirmation(
event.customerId, event.orderId
);
}
}
Repository Pattern
Repository ทำหน้าที่เก็บและดึง Aggregate Root จาก Storage โดยซ่อน Implementation Details:
// Repository Interface (Domain Layer)
interface OrderRepository {
findById(id: OrderId): Promise;
save(order: Order): Promise;
nextId(): OrderId;
}
// Implementation (Infrastructure Layer)
class PostgresOrderRepository implements OrderRepository {
async findById(id: OrderId): Promise {
const row = await this.db.query(
"SELECT * FROM orders WHERE id = $1", [id.value]
);
if (!row) return null;
return this.toDomain(row); // Map DB row to Domain Object
}
async save(order: Order): Promise {
const data = this.toPersistence(order);
await this.db.query(
`INSERT INTO orders (id, customer_id, status, total)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE SET status=$3, total=$4`,
[data.id, data.customerId, data.status, data.total]
);
}
}
Domain, Application, Infrastructure Services
// Domain Service — Business Logic ที่ไม่อยู่ใน Entity ตัวใดตัวหนึ่ง
class PricingService {
calculateDiscount(order: Order, customer: Customer): Money {
if (customer.isVIP() && order.totalAmount.isGreaterThan(Money.of(5000, "THB"))) {
return order.totalAmount.multiply(0.1); // VIP ลด 10%
}
return Money.zero();
}
}
// Application Service — Orchestrate Use Cases
class PlaceOrderUseCase {
constructor(
private orderRepo: OrderRepository,
private customerRepo: CustomerRepository,
private pricingService: PricingService,
private eventBus: EventBus,
) {}
async execute(cmd: PlaceOrderCommand): Promise {
const customer = await this.customerRepo.findById(cmd.customerId);
if (!customer) throw new NotFoundError("Customer not found");
const order = Order.create(this.orderRepo.nextId(), customer.id);
for (const item of cmd.items) {
order.addItem(item.productId, item.quantity, item.price);
}
const discount = this.pricingService.calculateDiscount(order, customer);
order.applyDiscount(discount);
order.confirm();
await this.orderRepo.save(order);
// Publish Domain Events
for (const event of order.pullDomainEvents()) {
await this.eventBus.publish(event);
}
return order.id;
}
}
// Infrastructure Service — Technical Concerns
class StripePaymentGateway implements PaymentGateway {
async charge(amount: Money, cardToken: string): Promise {
const result = await stripe.charges.create({
amount: amount.toCents(),
currency: amount.currency,
source: cardToken,
});
return new PaymentResult(result.id, result.status);
}
}
CQRS และ Event Sourcing กับ DDD
CQRS (Command Query Responsibility Segregation)
// Command Side — เขียนผ่าน Domain Model
class PlaceOrderCommand {
constructor(
readonly customerId: string,
readonly items: Array<{ productId: string; quantity: number; price: number }>
) {}
}
// Query Side — อ่านจาก Read Model (optimized)
class OrderQueryService {
async getOrderSummary(orderId: string): Promise {
// อ่านจาก Denormalized View/Table ที่ออกแบบมาเพื่อ Query
return this.readDb.query(
"SELECT * FROM order_summary_view WHERE id = $1", [orderId]
);
}
async getCustomerOrders(customerId: string): Promise {
return this.readDb.query(
"SELECT * FROM customer_orders_view WHERE customer_id = $1",
[customerId]
);
}
}
Event Sourcing
// แทนที่จะเก็บ State ปัจจุบัน เก็บ Event ทั้งหมดที่เกิดขึ้น
class Order {
private events: DomainEvent[] = [];
// Rebuild State จาก Events
static fromHistory(events: DomainEvent[]): Order {
const order = new Order();
for (const event of events) {
order.apply(event);
}
return order;
}
private apply(event: DomainEvent): void {
if (event instanceof OrderCreated) {
this.id = event.orderId;
this.status = OrderStatus.CREATED;
} else if (event instanceof ItemAdded) {
this.items.push(new OrderItem(event.productId, event.quantity, event.price));
} else if (event instanceof OrderConfirmed) {
this.status = OrderStatus.CONFIRMED;
}
}
}
// Event Store
class EventStore {
async save(aggregateId: string, events: DomainEvent[]): Promise {
for (const event of events) {
await this.db.query(
"INSERT INTO events (aggregate_id, type, data, version) VALUES ($1, $2, $3, $4)",
[aggregateId, event.constructor.name, JSON.stringify(event), event.version]
);
}
}
async load(aggregateId: string): Promise {
const rows = await this.db.query(
"SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version",
[aggregateId]
);
return rows.map(r => this.deserialize(r));
}
}
DDD กับ Microservices
Bounded Context สะท้อนไปเป็น Microservice ได้พอดี:
// แต่ละ Bounded Context = 1 Microservice
//
// [Order Service] ←→ [Payment Service]
// ↓ ↓
// [Shipping Service] ←→ [Inventory Service]
//
// สื่อสารผ่าน:
// 1. Domain Events (async, preferred)
// 2. REST/gRPC (sync, เมื่อจำเป็น)
// 3. Anti-Corruption Layer (เมื่อ integrate กับ Legacy)
Anti-Corruption Layer (ACL)
// ป้องกัน Model ของเราจาก External System
class LegacyOrderAdapter implements OrderDataSource {
constructor(private legacyApi: LegacyAPI) {}
async getOrder(orderId: string): Promise {
// Legacy API ส่ง data format แปลกๆ
const legacyData = await this.legacyApi.fetch_order_v1(orderId);
// ACL แปลงเป็น Domain Model ของเรา
return Order.reconstitute({
id: new OrderId(legacyData.ord_num),
customerId: new CustomerId(legacyData.cust_code),
status: this.mapStatus(legacyData.stat_flag),
items: legacyData.line_items.map(li => new OrderItem(
new ProductId(li.prod_cd),
li.qty_ord,
Money.of(li.unit_prc, "THB")
)),
});
}
private mapStatus(flag: number): OrderStatus {
const mapping: Record = {
0: OrderStatus.CREATED,
1: OrderStatus.CONFIRMED,
9: OrderStatus.CANCELLED,
};
return mapping[flag] ?? OrderStatus.UNKNOWN;
}
}
Event Storming Workshop
Event Storming คือ Workshop ที่ช่วยค้นหา Domain Events, Commands และ Aggregate:
- Domain Events (สีส้ม): เขียน Event ที่เกิดขึ้นในธุรกิจ เช่น "Order Placed", "Payment Received"
- Commands (สีน้ำเงิน): คำสั่งที่ทำให้เกิด Event เช่น "Place Order", "Process Payment"
- Aggregates (สีเหลือง): กลุ่มของ Data ที่ต้องเปลี่ยนพร้อมกัน เช่น "Order", "Payment"
- Bounded Contexts: วาดเส้นรอบกลุ่มที่เกี่ยวข้องกัน
- Policies (สีม่วง): เมื่อเกิด Event A ให้ทำ Command B อัตโนมัติ
DDD กับ Clean/Hexagonal Architecture
// Hexagonal Architecture (Ports & Adapters)
//
// [HTTP Controller] → [Application Service] → [Domain Model]
// ↓
// [Port Interface]
// ↓
// [Adapter: Database]
//
// โครงสร้าง Folder:
src/
├── domain/ # Domain Layer (ไม่ depend อะไรเลย)
│ ├── model/
│ │ ├── Order.ts # Aggregate Root
│ │ ├── OrderItem.ts # Entity
│ │ └── Money.ts # Value Object
│ ├── event/
│ │ └── OrderConfirmed.ts # Domain Event
│ ├── repository/
│ │ └── OrderRepository.ts # Interface (Port)
│ └── service/
│ └── PricingService.ts # Domain Service
├── application/ # Application Layer
│ ├── PlaceOrderUseCase.ts
│ └── dto/
│ └── PlaceOrderCommand.ts
├── infrastructure/ # Infrastructure Layer (Adapters)
│ ├── persistence/
│ │ └── PostgresOrderRepository.ts
│ ├── messaging/
│ │ └── RabbitMQEventBus.ts
│ └── external/
│ └── StripePaymentGateway.ts
└── interface/ # Interface Layer
├── http/
│ └── OrderController.ts
└── graphql/
└── OrderResolver.ts
DDD vs CRUD — เมื่อไหร่ควรใช้?
| ด้าน | CRUD | DDD |
|---|---|---|
| Complexity | ต่ำ-ปานกลาง | สูง (Complex Domain) |
| Development Speed | เร็ว (เริ่มต้น) | ช้ากว่า (เริ่มต้น) |
| Maintenance | ยากเมื่อซับซ้อนขึ้น | ง่ายกว่าในระยะยาว |
| Team Size | ทีมเล็ก | ทีมใหญ่หลาย Context |
| Business Rules | น้อย | มากและซับซ้อน |
| Learning Curve | ต่ำ | สูง |
ใช้ DDD เมื่อ:
- Business Logic ซับซ้อน มีกฎเกณฑ์เยอะ
- Domain เปลี่ยนแปลงบ่อย ต้องยืดหยุ่น
- ทีมใหญ่ ต้องแบ่ง Ownership ชัดเจน
- ระบบ Microservices ที่ต้องกำหนดขอบเขต
ไม่ต้องใช้ DDD เมื่อ:
- แอปง่ายๆ แค่ CRUD (Blog, TODO app)
- Prototype หรือ MVP ที่ต้องเร็ว
- ทีมเล็ก 1-3 คน Domain ไม่ซับซ้อน
- ระบบที่แทบไม่มี Business Logic (Data Pipeline)
Common DDD Mistakes
- Anemic Domain Model: Entity มีแต่ getter/setter ไม่มี Business Logic — Logic หนีไปอยู่ใน Service แทน
- Aggregate ใหญ่เกินไป: ใส่ทุกอย่างใน Aggregate เดียว ทำให้ Performance แย่และ Concurrency มีปัญหา
- ข้ามอ้างอิง Aggregate ด้วย Object: ต้องอ้างอิงด้วย ID เท่านั้น ไม่ใช่ Object Reference
- ไม่มี Ubiquitous Language: Developer ใช้ภาษาของตัวเอง ไม่ตรงกับ Business
- DDD Everywhere: ใช้ DDD กับทุกส่วนของระบบ แม้ส่วนที่ง่ายๆ — ทำให้ Over-engineering
- ลืม Event Storming: กระโดดเข้า Code เลย ไม่ทำ Workshop กับ Domain Expert
DDD กับภาษาต่างๆ
# Python กับ DDD (ใช้ dataclass)
from dataclasses import dataclass, field
from typing import List
@dataclass(frozen=True) # Value Object (immutable)
class Money:
amount: float
currency: str = "THB"
def add(self, other: "Money") -> "Money":
assert self.currency == other.currency
return Money(self.amount + other.amount, self.currency)
@dataclass
class Order: # Aggregate Root
id: str
customer_id: str
items: List["OrderItem"] = field(default_factory=list)
status: str = "CREATED"
_events: List = field(default_factory=list, repr=False)
def add_item(self, product_id: str, qty: int, price: Money):
if len(self.items) >= 50:
raise ValueError("Too many items")
self.items.append(OrderItem(product_id, qty, price))
def confirm(self):
if not self.items:
raise ValueError("Cannot confirm empty order")
self.status = "CONFIRMED"
self._events.append(OrderConfirmed(self.id, self.customer_id))
Testing Domain Logic
// Domain Logic ทดสอบง่ายมาก — ไม่ต้อง Mock Database
describe("Order", () => {
it("should confirm order with items", () => {
const order = Order.create(OrderId.generate(), customerId);
order.addItem(productId, 2, Money.of(500, "THB"));
order.confirm();
expect(order.status).toBe(OrderStatus.CONFIRMED);
expect(order.pullDomainEvents()).toContainEqual(
expect.objectContaining({ type: "OrderConfirmed" })
);
});
it("should reject confirming empty order", () => {
const order = Order.create(OrderId.generate(), customerId);
expect(() => order.confirm()).toThrow("Cannot confirm empty order");
});
it("should limit items to 50", () => {
const order = Order.create(OrderId.generate(), customerId);
for (let i = 0; i < 50; i++) {
order.addItem(productId, 1, Money.of(100, "THB"));
}
expect(() => order.addItem(productId, 1, Money.of(100, "THB")))
.toThrow("more than 50 items");
});
});
สรุป
Domain-Driven Design ไม่ใช่แค่ Pattern ในระดับ Code แต่เป็นวิธีคิดในการออกแบบ Software ที่ซับซ้อน หัวใจสำคัญคือ: ให้ Code สะท้อน Business Domain, ใช้ภาษาเดียวกับ Domain Expert, แบ่ง System เป็น Bounded Contexts ที่ชัดเจน
เริ่มต้นจากการทำ Event Storming กับทีม, กำหนด Ubiquitous Language, แล้วค่อยๆ นำ Tactical Patterns (Entity, Value Object, Aggregate, Domain Events) มาใช้ อย่าพยายามใช้ DDD กับทุกอย่าง — ใช้กับส่วนที่ซับซ้อนจริงๆ และปล่อยส่วนที่ง่ายเป็น CRUD ธรรมดา
