Domain-Driven Design: Core Concepts
Giới thiệu
Domain-Driven Design (DDD) không phải framework hay pattern. Nó là mindset: tư duy từ business problem trước, rồi reflect lại vào code structure.
Tại sao quan trọng? Vì khi hệ thống phát triển phức tạp, code càng dễ trở thành "big ball of mud" nếu không có mô hình miền (domain model) rõ ràng. DDD giúp bạn:
- Giao tiếp với business bằng ngôn ngữ chung (Ubiquitous Language)
- Tách biệt concern rõ ràng (không phải toán học trừu tượng, mà từ business)
- Quyết định scope một cách có lý do
Problem: Tại sao code lại rối?
Hình dung bạn xây dựng một platform e-commerce. Ban đầu:
// "Tao code tổng quát thôi"
type Order struct {
ID string
Items []Item
TotalPrice float64
Status string // "pending", "processing", "delivered"...
Customer Customer
Address Address
PaymentMethod PaymentMethod
Notes string
}
type Customer struct {
ID string
Name string
Email string
}
func ProcessOrder(order *Order) {
// Kiểm tra inventory
// Charge payment
// Update database
// Send email
// Log analytics
// Calculate commission
// Sync to accounting system
// Update recommendation engine
}
Vấn đề:
- Order là gì? Bộ data dump hay domain concept? Không rõ
- ProcessOrder làm gì? 10+ responsibilities khác nhau - ai test? Ai maintain?
- Business logic ở đâu? Nằm rải rác trong helper functions, utilities, transactions
- Khi business thay đổi rule (e.g., "chỉ charge payment sau 24h nếu là credit card") → cơm cháy vì logic phân tán
DDD giải quyết bằng cách: model hóa domain đúng, rồi code theo model đó.
Core Concept 1: Ubiquitous Language
Định nghĩa: Ngôn ngữ chung giữa engineers, product, business — cả 3 nói cùng 1 vocab không nhập nhằng.
Ví dụ sai:
- Engineer: "Order có trạng thái is_processed = true"
- Product: "Đơn hàng đã được confirm?"
- Business: "Chúng ta cần track khi nào thanh toán được xác minh" → Ai biết là cùng 1 concept? 🤔
Ví dụ đúng:
- Cả ba đều dùng: "Order ở trạng thái PaymentVerified"
- Code:
OrderStatus.PaymentVerified(constant) - Database: status column có giá trị
payment_verified - Document: "PaymentVerified nghĩa là thanh toán đã pass 3D Secure"
Thực hành:
- Tổ chức meeting giữa engineer + product + business
- Liệt kê terms: "Order, Payment, Shipment, Refund, ..." và định nghĩa chính xác
- Viết vào docs hoặc Slack thread để reference
- Code tuân theo vocab này
Lợi ích:
- Reduce miscommunication (vừa code vừa giải thích ý tưởng? Được)
- Code review dễ: "Cái này không align với ubiquitous language"
- Onboarding nhanh: engineer mới hiểu domain luôn
Core Concept 2: Bounded Context
Định nghĩa: Ranh giới rõ ràng của một domain model. Bên trong context, các rules áp dụng; bên ngoài, context khác có rules riêng.
Real-world analogy: Ngân hàng có 3 bộ phận — Retail, Corporate, Treasury. Từ "Customer" có ý nghĩa khác nhau trong mỗi context:
- Retail: Customer là cá nhân có tài khoản tiết kiệm
- Corporate: Customer là công ty có credit facility
- Treasury: Customer là counterparty trong swap contract
Ví dụ trong e-commerce:
┌─────────────────────────────────────────┐
│ ORDERS Context │
│ - Order aggregate │
│ - Order state: Created → Confirmed... │
│ - Business rule: "Order phải có >= 1 │
│ item" │
└────────────┬──────────────────────────────┘
│ triggers
↓
┌─────────────────────────────────────────┐
│ FULFILLMENT Context │
│ - Shipment aggregate │
│ - Shipment state: Pending → Shipped... │
│ - Business rule: "Một Shipment không │
│ có deadline" (Orders context có) │
└─────────────────────────────────────────┘
│ publishes event
↓
┌─────────────────────────────────────────┐
│ PAYMENT Context │
│ - Transaction aggregate │
│ - Rules: PCI compliance, idempotency │
└─────────────────────────────────────────┘
Key insight: Order trong ORDERS Context ≠ Order trong FULFILLMENT Context (về semantics).
- ORDERS Order: "Có gì hạn chế về thanh toán không?"
- FULFILLMENT Order: "Tao pack gì?". Order ở đây là reference (
order_id), không phải embedded model
Trong code Go:
// Package: order/order.go
package order
type Order struct {
ID string
Items []Item
CreatedAt time.Time
// ❌ WRONG: shipmentID := shipment.ID - coupling context
// ✓ RIGHT: Chỉ store ID để reference
ShipmentID string
}
type Item struct {
ProductID string
Quantity int
}
// Package: fulfillment/shipment.go
package fulfillment
type Shipment struct {
ID string
OrderID string // ← reference qua ID, không embed Order
Items []ShipmentItem
}
type ShipmentItem struct {
ProductID string
Quantity int
}
Giao tiếp giữa contexts:
- Event-driven: Order context publish
OrderCreatedevent → Fulfillment context subscribe - API: Fulfillment gọi Order API để query order details khi cần
- Database: Mỗi context có database riêng (microservices style) hoặc schema riêng (monolith style)
Common pitfall: Chia services theo technical layer (UserService, OrderService) thay vì business context → Kết quả: cyclic dependency, duplicate logic, confusion
Core Concept 3: Aggregate
Định nghĩa: Cluster of domain objects (entities + value objects) mà được treat như 1 unit từ business perspective. Aggregate có root entity (aggregate root).
Tại sao cần?
- Clear boundary: Khỏi phải suy nghĩ "ai modify cái này?"
- Consistency guarantee: Aggregate root chịu trách nhiệm consistency
- Transaction boundary: Transaction bao toàn aggregate, không cross aggregates
- Database query pattern: Luôn load full aggregate (hoặc không load)
Ví dụ: Order Aggregate
// ✓ GOOD: Aggregate được thiết kế rõ ràng
package order
type Order struct { // Aggregate Root
id string
items []OrderItem // Part của aggregate
customer Customer // Part của aggregate
status OrderStatus
createdAt time.Time
}
type OrderItem struct {
productID string
quantity int
price Money // Value Object
}
type Customer struct {
id string
email string
// Chỉ data cần thiết trong context này
}
type Money struct {
amount int // cents
currency string // "USD", "VND"
}
// ✓ Business rule được enforce bởi aggregate
func (o *Order) AddItem(productID string, qty int, price Money) error {
if qty <= 0 {
return errors.New("quantity must be > 0")
}
if o.status != OrderStatusCreated {
return errors.New("cannot add item to confirmed order")
}
o.items = append(o.items, OrderItem{
productID: productID,
quantity: qty,
price: price,
})
return nil
}
func (o *Order) Total() Money {
total := 0
for _, item := range o.items {
total += item.price.amount * item.quantity
}
return Money{amount: total, currency: o.items[0].price.currency}
}
func (o *Order) Confirm() error {
if len(o.items) == 0 {
return errors.New("order must have at least 1 item")
}
o.status = OrderStatusConfirmed
return nil
}
❌ Anti-pattern: Anemic domain model
// BAD: Aggregate là dumb data container
type Order struct {
ID string
Items []OrderItem
Status string
}
// BAD: Business logic bên ngoài, được gọi từ service layer
type OrderService struct {
db *Database
}
func (s *OrderService) AddItemToOrder(orderID, productID string, qty int) error {
order, _ := s.db.GetOrder(orderID)
if qty <= 0 { // ← Rule nằm ở service, không phải aggregate
return errors.New("qty invalid")
}
if order.Status != "created" { // ← Lại check status ở đây
return errors.New("cannot add")
}
order.Items = append(order.Items, OrderItem{...})
return s.db.SaveOrder(order)
}
// Khi business rule thay đổi:
// 1. Modify OrderService
// 2. Nhưng OrderService được gọi từ HTTP handler, GRPC handler, scheduled job...
// → Rule bị duplicate ở 3 chỗ!
Aggregate design heuristic:
- Root: Là entity "chính", business care nhất (Order, ShoppingCart, User)
- Internal entities: Entity khác nằm trong aggregate (OrderItem)
- Value objects: Immutable data (Money, Email, Address)
- Size: Aggregates nên nhỏ. Nếu có 100 objects → bạn model sai
Core Concept 4: Entity vs Value Object
| Entity | Value Object | |
|---|---|---|
| Identity | Có unique ID, define by identity | Không ID, define by attributes |
| Mutability | Mutable | Immutable |
| Lifecycle | Có lifecycle (create → update → delete) | Không lifecycle |
| Equality | a == b nếu same ID |
a == b nếu same value |
| Example | Order (ID: order123), Customer | Money (100 USD), Email (john@example.com) |
Ví dụ:
// ENTITY: Order
type Order struct {
ID string // ← Identity
Items []OrderItem
Status string
}
order1 := Order{ID: "order123", Items: [...], Status: "pending"}
order2 := Order{ID: "order123", Items: [...], Status: "confirmed"}
// order1 == order2? NO (different status) → nhưng là cùng 1 Order
// Nên: order1.ID == order2.ID → là cùng domain object
// VALUE OBJECT: Money (Immutable)
type Money struct {
Amount int
Currency string
}
money1 := Money{100, "USD"}
money2 := Money{100, "USD"}
// money1 == money2? YES (same value)
// VALUE OBJECT: Email
type Email struct {
value string
}
func NewEmail(s string) (Email, error) {
if !strings.Contains(s, "@") {
return Email{}, errors.New("invalid email")
}
return Email{value: s}, nil
}
email1, _ := NewEmail("john@example.com")
email2, _ := NewEmail("john@example.com")
// email1 == email2? YES (same value)
// ✓ When to use value objects:
// - Trong order aggregate, address là value object (không care unique identity, chỉ care attributes)
type OrderAddress struct {
Street string
City string
ZipCode string
}
// ✓ Immutability in Go
type Money struct {
amount int // private
currency string // private
}
func (m Money) Add(other Money) (Money, error) {
if m.currency != other.currency {
return Money{}, errors.New("currency mismatch")
}
return Money{
amount: m.amount + other.amount,
currency: m.currency,
}, nil
}
// m.Add(...) returns NEW Money, không mutate m
// Prevent copy-and-modify:
func (m *Money) Multiply(factor int) Money { // ← pointer, không value
// Prevents: money.Multiply(...) from returning struct by value
}
Lợi ích immutable value objects:
- Thread-safe (no lock needed)
- Easier to reason about
- Can be used as map keys
Core Concept 5: Repository Pattern
Định nghĩa: Abstraction giữa domain model và persistence layer. Repository hide cách data được stored (DB, cache, file, ...).
// Domain layer: repository.go
package order
type Order struct {
ID string
Items []OrderItem
}
type Repository interface {
Save(ctx context.Context, order *Order) error
FindByID(ctx context.Context, id string) (*Order, error)
FindAll(ctx context.Context) ([]*Order, error)
}
// Infrastructure layer: postgres_repository.go
package postgres
type OrderRepository struct {
db *pgx.Conn
}
func (r *OrderRepository) Save(ctx context.Context, order *Order) error {
// Translate Order → database rows
// Handle transactions
// Return error if PK conflict, etc.
}
func (r *OrderRepository) FindByID(ctx context.Context, id string) (*Order, error) {
// Query database
// Translate rows → Order
}
// Application layer: service.go
package app
type OrderService struct {
repo order.Repository
}
func (s *OrderService) ConfirmOrder(ctx context.Context, orderID string) error {
order, err := s.repo.FindByID(ctx, orderID)
if err != nil {
return err
}
if err := order.Confirm(); err != nil {
return err
}
return s.repo.Save(ctx, order)
}
// ✓ Benefit: Easy to test
type MockOrderRepository struct {
orders map[string]*Order
}
func (m *MockOrderRepository) Save(ctx context.Context, order *Order) error {
m.orders[order.ID] = order
return nil
}
func TestConfirmOrder(t *testing.T) {
repo := &MockOrderRepository{orders: make(map[string]*Order)}
service := &OrderService{repo: repo}
// No database needed!
err := service.ConfirmOrder(context.Background(), "order123")
}
Advanced: Query Repository vs Write Repository
// Write model: For commands
type OrderRepository interface {
Save(ctx context.Context, order *Order) error
Delete(ctx context.Context, id string) error
}
// Read model: For queries (optimized for reads)
type OrderReadRepository interface {
FindByID(ctx context.Context, id string) (*OrderDTO, error)
FindByCustomer(ctx context.Context, customerID string) ([]*OrderDTO, error)
FindRecentOrders(ctx context.Context, limit int) ([]*OrderDTO, error)
}
// ✓ Benefit: Write model stays simple (full Order aggregate), read model optimized
Core Concept 6: Domain Events
Định nghĩa: Event that occurred trong domain. Khi order được confirmed, publish OrderConfirmed event.
// Domain event
package order
type OrderConfirmed struct {
OrderID string
CustomerID string
TotalAmount Money
OccurredAt time.Time
}
// Domain model generates event
type Order struct {
ID string
Items []OrderItem
status OrderStatus
events []interface{} // ← Buffer events
}
func (o *Order) Confirm() error {
if len(o.items) == 0 {
return errors.New("empty order")
}
o.status = OrderStatusConfirmed
// ✓ Publish event (domain doesn't care who listens)
o.events = append(o.events, OrderConfirmed{
OrderID: o.ID,
CustomerID: o.customerID,
TotalAmount: o.Total(),
OccurredAt: time.Now(),
})
return nil
}
func (o *Order) PendingEvents() []interface{} {
return o.events
}
func (o *Order) ClearEvents() {
o.events = nil
}
// Application layer: handle events
package app
func (s *OrderService) ConfirmOrder(ctx context.Context, orderID string) error {
order, _ := s.repo.FindByID(ctx, orderID)
if err := order.Confirm(); err != nil {
return err
}
if err := s.repo.Save(ctx, order); err != nil {
return err
}
// ✓ Publish events
for _, event := range order.PendingEvents() {
s.eventBus.Publish(ctx, event)
}
order.ClearEvents()
return nil
}
// Other contexts subscribe
package fulfillment
type OrderConfirmedHandler struct {
shipmentService *ShipmentService
}
func (h *OrderConfirmedHandler) Handle(event order.OrderConfirmed) {
// Create shipment
h.shipmentService.CreateShipment(event.OrderID, event.CustomerID)
}
Benefits:
- Loose coupling: Order context không depend on Fulfillment
- Event sourcing ready: Can rebuild state từ events
- Async processing: Handle events trong background job
Pitfall: Publishing events nhưng transaction fail
- Solution: Transactional outbox pattern (advanced topic)
Tóm tắt
| Concept | Tác dụng | Gotcha |
|---|---|---|
| Ubiquitous Language | Ngôn ngữ chung, reduce miscommunication | Cần setup từ đầu project |
| Bounded Context | Clear scope, independent evolution | Overuse → microservices chaos |
| Aggregate | Business logic centralized, consistency | Aggregate size explosion |
| Entity vs VO | Model richness, testability | Confusion khi nào dùng gì |
| Repository | Abstraction, testability | Can become "God" object |
| Domain Events | Loose coupling, event sourcing | Handling failure mode |
Khi nào dùng DDD?
Good fit:
- Dự án 6+ tháng, growing codebase
- Domain complexity cao (multiple business rules, edge cases)
- Team > 3 people
- Long-term maintenance priority
Overkill:
- CRUD app (blog, simple data form)
- Prototype, 1-person project
- Clear, linear business flow (no edge case)
Real-world red flag: "We did DDD nhưng code vẫn là big ball of mud" → Thường do:
- Chỉ follow syntax (create classes), bỏ qua spirit (model business)
- Aggregates quá lớn (vì "dễ code")
- Domain knowledge không được share (engineer không hiểu business)
Bước tiếp theo
- Distributed Systems: Khi DDD meets microservices — context communication, saga pattern
- System Design: Aggregate consistency vs eventual consistency trade-off
- Event Sourcing: Store change history thay vì state