簡介
領域驅動設計 (DDD) 有助於將複雜的系統分離成清晰的有界上下文——每個上下文都成為一個微服務。結合 Database per Service 模式,每個服務都有自己的資料庫,確保鬆散耦合和獨立的可部署性。
有界上下文和上下文映射
電商域名分解
┌─────────────────────────────────────────────────────┐
│ E-Commerce Platform │
├──────────┬──────────┬──────────┬────────┬───────────┤
│ Product │ Order │ Payment │ User │Notification│
│ Context │ Context │ Context │Context │ Context │
├──────────┼──────────┼──────────┼────────┼───────────┤
│•Product │•Order │•Payment │•User │•Notification│
│•Category │•OrderItem│•Refund │•Role │•Template │
│•Inventory│•Cart │•Invoice │•Profile│•Channel │
│•Review │ │ │ │ │
└──────────┴──────────┴──────────┴────────┴───────────┘
上下文映射——服务之间的关系
| 上游 | 下游 | 關係式 |
|---|---|---|
| 产品服务 | 订购服务 | 客户-供应商 |
| 订购服务 | 支付服务 | 客户-供应商 |
| 支付服务 | 通知 | 活动发布者 |
| 订购服务 | 通知 | 活动发布者 |
| 用戶(Keycloak) | 所有服務 | 墨守成規者 (OIDC) |
聚合根模式
產品聚合
@Entity
@Table(name = "products")
public class Product extends PanacheEntity {
// === Aggregate Root ===
@Column(nullable = false)
public String name;
public String description;
@Embedded
public Money price; // Value Object
@Embedded
public StockInfo stock; // Value Object
@Column(nullable = false)
public String status; // DRAFT, ACTIVE, DISCONTINUED
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
public Category category;
@OneToMany(mappedBy = "product",
cascade = CascadeType.ALL,
orphanRemoval = true)
public List<ProductImage> images = new ArrayList<>();
// === Domain Methods ===
public void activate() {
if (this.price == null || this.price.amount()
.compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(400,
"Sản phẩm cần có giá > 0 để kích hoạt");
}
this.status = "ACTIVE";
}
public void reduceStock(int quantity) {
if (this.stock.available() < quantity) {
throw new BusinessException(400,
"Không đủ tồn kho: cần " + quantity
+ ", còn " + this.stock.available());
}
this.stock = this.stock.reduce(quantity);
}
public void addImage(String url, int sortOrder) {
ProductImage image = new ProductImage();
image.url = url;
image.sortOrder = sortOrder;
image.product = this;
this.images.add(image);
}
}
值對象
import jakarta.persistence.Embeddable;
import java.math.BigDecimal;
import java.util.Currency;
@Embeddable
public record Money(
@Column(name = "price_amount", precision = 12, scale = 2)
BigDecimal amount,
@Column(name = "price_currency", length = 3)
String currency
) {
public Money {
if (amount != null && amount.compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException(
"Amount cannot be negative");
}
if (currency == null) currency = "VND";
}
public static Money vnd(BigDecimal amount) {
return new Money(amount, "VND");
}
public Money add(Money other) {
assertSameCurrency(other);
return new Money(amount.add(other.amount), currency);
}
public Money multiply(int quantity) {
return new Money(
amount.multiply(BigDecimal.valueOf(quantity)), currency);
}
private void assertSameCurrency(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException(
"Cannot operate on different currencies");
}
}
}
@Embeddable
public record StockInfo(
@Column(name = "stock_available")
int available,
@Column(name = "stock_reserved")
int reserved
) {
public int total() { return available + reserved; }
public StockInfo reduce(int quantity) {
return new StockInfo(available - quantity, reserved);
}
public StockInfo reserve(int quantity) {
return new StockInfo(available - quantity,
reserved + quantity);
}
}
每个服务的数据库
多模組Maven結構
ecommerce-platform/
├── pom.xml # Parent POM
├── product-service/
│ ├── pom.xml
│ └── src/main/resources/
│ ├── application.properties # DB: product_db
│ └── db/migration/ # Flyway cho product_db
├── order-service/
│ ├── pom.xml
│ └── src/main/resources/
│ ├── application.properties # DB: order_db
│ └── db/migration/ # Flyway cho order_db
├── payment-service/
│ ├── pom.xml
│ └── src/main/resources/
│ ├── application.properties # DB: payment_db
│ └── db/migration/
└── notification-service/
├── pom.xml
└── src/main/resources/
├── application.properties # DB: notification_db
└── db/migration/
为每个服务配置单独的数据库
# product-service/application.properties
quarkus.application.name=product-service
quarkus.http.port=8081
%prod.quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:5432/product_db
%prod.quarkus.datasource.username=${DB_USERNAME}
%prod.quarkus.datasource.password=${DB_PASSWORD}
# order-service/application.properties
quarkus.application.name=order-service
quarkus.http.port=8082
%prod.quarkus.datasource.db-kind=postgresql
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:5432/order_db
%prod.quarkus.datasource.username=${DB_USERNAME}
%prod.quarkus.datasource.password=${DB_PASSWORD}
Flyway 資料庫遷移
依賴關係
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-flyway</artifactId>
</dependency>
配置
# Flyway chạy tự động khi start
quarkus.flyway.migrate-at-start=true
quarkus.flyway.baseline-on-migrate=true
# Production: KHÔNG dùng hibernate auto-generation
%prod.quarkus.hibernate-orm.database.generation=validate
遷移文件
-- src/main/resources/db/migration/V1.0.0__create_products_table.sql
CREATE TABLE categories (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price_amount NUMERIC(12,2) NOT NULL,
price_currency VARCHAR(3) DEFAULT 'VND',
stock_available INT NOT NULL DEFAULT 0,
stock_reserved INT NOT NULL DEFAULT 0,
category_id BIGINT REFERENCES categories(id),
status VARCHAR(20) DEFAULT 'DRAFT',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_products_category ON products(category_id);
CREATE INDEX idx_products_status ON products(status);
-- V1.1.0__add_product_images.sql
CREATE TABLE product_images (
id BIGSERIAL PRIMARY KEY,
product_id BIGINT NOT NULL REFERENCES products(id)
ON DELETE CASCADE,
url VARCHAR(500) NOT NULL,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_product_images_product
ON product_images(product_id);
-- V1.2.0__add_full_text_search.sql
ALTER TABLE products
ADD COLUMN search_vector tsvector;
CREATE INDEX idx_products_search
ON products USING gin(search_vector);
CREATE OR REPLACE FUNCTION update_product_search_vector()
RETURNS trigger AS $$
BEGIN
NEW.search_vector :=
to_tsvector('english',
COALESCE(NEW.name, '') || ' ' ||
COALESCE(NEW.description, ''));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_products_search_vector
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION update_product_search_vector();
反腐敗層
當Order Service需要Product資訊時,不要直接查詢product_db。相反,使用 REST 客户端或事件:
// order-service: Product representation (không phải Product entity)
public record ProductInfo(
Long productId,
String name,
BigDecimal price,
String currency
) {}
// Anti-corruption layer: translate external data
@ApplicationScoped
public class ProductAdapter {
@Inject
@RestClient
ProductServiceClient productClient;
public ProductInfo getProduct(Long productId) {
// Call Product Service REST API
var external = productClient.getById(productId);
// Map sang internal representation
return new ProductInfo(
external.id(),
external.name(),
external.price(),
"VND"
);
}
}
实体 vs 值对象 — 何时使用什么?
| 特点 | 实体 | 值对象 |
|---|---|---|
| 身分 | 擁有唯一的ID | 沒有 ID,使用 |
| 可變性 | 可以改變狀態 | 不可變(Java 記錄) |
| 生命週期 | 有自己的生命週期 | 屬於實體 |
| 堅持 | @Entity — 私人餐桌 | @Embeddable — 与实体相同的表 |
| 範例 | 產品、訂單、使用者 | 金錢、地址、庫存資訊 |
// Entity — có identity, mutable
@Entity
public class Order extends PanacheEntity {
public Long id; // Identity
public String status; // State changes
}
// Value Object — no identity, immutable
@Embeddable
public record Address(
String street, String city,
String province, String postalCode
) {
// So sánh bằng giá trị, không phải ID
// Java record tự generate equals/hashCode
}
// Khi nào tạo Value Object mới?
// → Nếu 2 objects có cùng giá trị = cùng 1 thing
// VD: Money(100_000, "VND") == Money(100_000, "VND")
// Address("123 ABC", "HCM") == Address("123 ABC", "HCM")
領域事件-限界上下文之間的通信
为什么我们需要领域事件?
Scenario: Customer đặt hàng thành công
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
❌ Cách sai: Order Service gọi trực tiếp
Order → call Product.reduceStock()
Order → call Payment.createPayment()
Order → call Notification.sendEmail()
→ Tight coupling, nếu 1 service down = toàn bộ fail
✅ Cách đúng: Publish domain event
Order → publish "OrderCreated" event
Product Service → subscribe → reduceStock()
Payment Service → subscribe → createPayment()
Notification → subscribe → sendEmail()
→ Loose coupling, mỗi subscriber xử lý độc lập
定義領域事件
// === Base Event ===
public abstract class DomainEvent {
private final String eventId = UUID.randomUUID().toString();
private final Instant occurredAt = Instant.now();
private final String eventType;
private final String aggregateId;
protected DomainEvent(String eventType,
String aggregateId) {
this.eventType = eventType;
this.aggregateId = aggregateId;
}
// getters...
}
// === Concrete Events ===
public class OrderCreatedEvent extends DomainEvent {
private final Long orderId;
private final String customerId;
private final List<OrderItemData> items;
private final BigDecimal totalAmount;
public OrderCreatedEvent(Long orderId,
String customerId,
List<OrderItemData> items,
BigDecimal totalAmount) {
super("ORDER_CREATED", orderId.toString());
this.orderId = orderId;
this.customerId = customerId;
this.items = items;
this.totalAmount = totalAmount;
}
public record OrderItemData(
Long productId,
int quantity,
BigDecimal unitPrice
) {}
}
public class PaymentCompletedEvent extends DomainEvent {
private final Long paymentId;
private final Long orderId;
private final BigDecimal amount;
private final String paymentMethod;
public PaymentCompletedEvent(Long paymentId,
Long orderId,
BigDecimal amount,
String paymentMethod) {
super("PAYMENT_COMPLETED", paymentId.toString());
this.paymentId = paymentId;
this.orderId = orderId;
this.amount = amount;
this.paymentMethod = paymentMethod;
}
}
public class StockReservedEvent extends DomainEvent {
private final Long productId;
private final int quantity;
private final Long orderId;
public StockReservedEvent(Long productId,
int quantity,
Long orderId) {
super("STOCK_RESERVED", productId.toString());
this.productId = productId;
this.quantity = quantity;
this.orderId = orderId;
}
}
电子商务中的事件流
Order Service Product Service
───────────── ───────────────
createOrder()
│
▼
publish OrderCreatedEvent ──┐
│ │
▼ ▼
Status: CREATED subscribe: reserveStock()
│
▼
publish StockReservedEvent ──┐
│
Payment Service │
─────────────── │
│◀─────────────────────────────────────────────┘
▼
subscribe: createPayment()
│
▼
VNPay callback → payment success
│
▼
publish PaymentCompletedEvent ──┐
│
Order Service │
│◀─────────────────────────┘
▼
subscribe: confirmOrder()
│
▼
Status: CONFIRMED
│
▼
publish OrderConfirmedEvent ──┐
│
Notification Service │
│◀───────────────────────┘
▼
subscribe: sendOrderConfirmationEmail()
資料一致性 — 最終一致性與 Saga 預覽
問題:沒有分散式事務
每個服務的資料庫 = 不能使用兩階段提交。相反:
| 圖案 | 說明 | 何時使用 |
|---|---|---|
| 最終一致性 | 資料「最終」將透過事件保持一致 | 通知、分析、快取 |
| 傳奇(編舞) | 每個服務發布事件,其他服務做出反應 | 簡單,服務很少(~3) |
| 傳奇(編曲) | 有一個協調器來協調序列 | 複雜,步驟多 |
| 寄件匣圖案 | 透過交易將事件寫入資料庫,輪詢到 Kafka | 保證至少一次交付 |
Saga Choreography cho Order Flow:
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Order Product Payment Notification
───── ─────── ─────── ────────────
Create ──▶ Reserve ──▶ Charge ──▶ Notify
Order Stock Payment Email
Nếu Payment fail:
─────────────────
Create ──▶ Reserve ──▶ Charge ──▶ ❌
Order Stock (FAIL)
│
▼
Release ◀── PaymentFailed
Stock (Compensating Action)
│
▼
Cancel ◀── StockReleased
Order
Saga 和寄件匣模式詳細資訊:第 15 課(Kafka 事件驅動)將全面實施。
DDD 聚合規則摘要
┌─────────────────────────────────────────────────────┐
│ DDD AGGREGATE RULES │
│ │
│ 1. Mỗi Aggregate có 1 Root Entity │
│ → Order là root, OrderItem là child │
│ │
│ 2. Truy cập child entities qua Root only │
│ ✅ order.addItem(product, qty) │
│ ❌ orderItemRepo.save(item) │
│ │
│ 3. Domain logic trong Aggregate, không ở Service │
│ ✅ order.cancel(reason) │
│ ❌ orderService.cancelOrder(orderId, reason) │
│ │
│ 4. 1 Transaction = 1 Aggregate │
│ → Không update Order + Product trong 1 TX │
│ → Dùng Domain Event thay thế │
│ │
│ 5. Reference giữa Aggregates bằng ID, không FK │
│ ✅ order.customerId (String) │
│ ❌ order.customer (User entity — wrong!) │
│ │
│ 6. Aggregate size nên nhỏ │
│ → Product + ProductImage = OK │
│ → Product + Category + Review = TOO BIG │
└─────────────────────────────────────────────────────┘
練習
- 將電子商務領域分析為有界上下文,繪製情境圖
- 創建
Product具有值物件的聚合根 (Money,StockInfo,Address) - 實作域方法:
activate(),reduceStock(),addImage() - 使用以下命令建立多模組 Maven 項目
product-service和order-service - 為產品資料庫架構編寫 Flyway 遷移(V1.0.0 → V1.2.0)
- 在訂單服務中創建反腐敗層
7.領域事件的定義:
OrderCreatedEvent,PaymentCompletedEvent,StockReservedEvent - 繪製訂單建立→付款→通知的事件流程圖
- 比較實體與值物件-何時使用
@Entity與@Embeddable記錄?
總結
- 有界上下文 = 1個微服務,每個上下文都有自己的語言(Ubiquitous Language)
- 聚合根確保業務不變性,包含域邏輯,僅透過根存取子項
- 值物件(記錄)用於不可變域概念(金錢、地址、股票資訊)
- 實體 vs 值物件:實體有 ID + 生命週期,值物件以值進行比較
- 每個服務都有資料庫 — 每個服務都有自己的資料庫,透過 API/事件進行通信
- Flyway 管理資料庫模式版本控制,自動執行遷移
- 反腐敗層防止領域模型被外部概念“感染”
- 域事件用於服務之間的鬆散耦合 - 發布事件而不是直接呼叫它們
- Saga 模式(編排/編排)用於服務之間的資料一致性
下一篇:建立完整的產品服務和訂單服務。