🌏 閱讀中文版本
Have you experienced this scenario: Your product manager says “Support a new discount rule,” and suddenly you’re modifying OrderService, DiscountService, PromotionService, PricingService, and CustomerService. By the time you’re done, you realize this “simple” requirement touched 5 different modules.
This isn’t an edge case. It’s a symptom of poor system design.
Domain-Driven Design (DDD) isn’t a new concept, but many engineers dismiss it as “complex” or “over-engineering.” The irony? DDD exists to do the opposite: transform complexity into simplicity through clear boundaries and well-defined responsibilities.
This article doesn’t dive into DDD’s history or academic definitions. We start with pain points and explore, through three different roles’ perspectives, how DDD solves fundamental system design problems.
Core Concepts: DDD’s Four Pillars
Before diving into the stories, let’s establish common language. DDD rests on four tightly interconnected concepts:
1. Aggregate – The Guardian of Business Rules
What is an aggregate?
An aggregate is a domain object that enforces all business rules within its scope. Simply put: an aggregate is a self-protecting entity that forbids operations violating business rules.
In an order system, Order is an aggregate. It manages OrderLineItem, OrderDiscount, and other child objects. Crucially, Order won’t let you directly modify a LineItem’s price. All business validations (Is discount valid? Is stock available? Does customer have sufficient credit?) are Order’s responsibility.
Why does it matter?
Without aggregates, business logic scatters: – Discount service validates discount validity – Customer service validates customer credit – Inventory service validates stock – Order service orchestrates all validations
Result: Changing any business rule requires modifying multiple places.
With aggregates, logic centralizes:
Order.applyDiscount(code) { // Order validates all rules internally // Change rules only in Order }
Aggregate Boundary and Protection
graph LR
User["🧑 External Caller"]
subgraph Agg ["📦 Order Aggregate"]
Root["Order Aggregate Root"]
Child1["LineItem Entity 1"]
Child2["LineItem Entity 2"]
Val["OrderDiscount Value Object"]
Rules["✅ Validation Rules
• Stock check
• Discount legality
• Price validity
• Customer credit"]
end
User -->|Can only call
applyDiscount method| Agg
Root --> Child1
Root --> Child2
Root --> Val
Root --> Rules
BadWay["❌ Not allowed
Direct modification
of LineItem.price"]
User -.->|Forbidden| BadWay
style Agg fill:#f3e5f5
style Root fill:#9c27b0,color:#fff
style Rules fill:#ce93d8
style BadWay fill:#ffcdd2
2. Ubiquitous Language – Cross-Role Communication
What is ubiquitous language?
Developers, product managers, and business analysts use the same vocabulary. Not “our Order Service,” but “Order Aggregate.” Not “user table,” but “Customer.”
It sounds trivial but carries immense power. When everyone says “An order cannot be modified after payment” instead of “post_status = 2 blocks updates,” understanding becomes instant and accurate.
3. Bounded Context – Clear Boundaries
What is a bounded context?
Complex systems span multiple business domains. Shopping Cart and Order systems seem related, but they define “Customer” differently:
- Shopping Cart BC: Customer = ID + Shopping Preferences + Browse History
- Order BC: Customer = ID + Shipping Address + Payment Method + Credit Limit
If both systems share one Customer object, any change ripples through both:
- Cart adds “Shopping Preferences” field
- Order service forced to update Customer schema
- Payment service forced to update Customer schema
Result: Customer becomes a bloated mess, each service uses only 20% of its fields.
DDD’s solution: Each bounded context owns its Customer definition. They communicate through events, not shared databases.
Multiple Bounded Contexts with Independent Designs
graph LR
subgraph Cart["🛒 Shopping Cart BC
Shopping Cart Context"]
C["Customer"]
C1["- CustomerID
- Browse History
- Shopping Preferences"]
C --> C1
end
subgraph Order["📦 Order BC
Order Context"]
O["Customer"]
O1["- CustomerID
- Shipping Address
- Payment Method
- Credit Limit"]
O --> O1
end
subgraph Payment["💳 Payment BC
Payment Context"]
P["Customer"]
P1["- CustomerID
- Bank Card
- Risk Score"]
P --> P1
end
EventBus["📡 Event Bus
Loose Coupling Communication"]
Cart -->|Publish Event
CartCreated| EventBus
Order -->|Publish Event
OrderPlaced| EventBus
Payment -->|Publish Event
PaymentSucceeded| EventBus
EventBus -->|Subscribe| Cart
EventBus -->|Subscribe| Order
EventBus -->|Subscribe| Payment
style C fill:#c8e6c9
style O fill:#f3e5f5
style P fill:#fff3e0
4. Domain Event – Async Decoupling
What is a domain event?
When an order is paid, the system shouldn’t directly call “deduct inventory,” “generate invoice,” “update recommendations.” Instead, publish an event: “Order Paid.”
Each system subscribes to this event: – Inventory system: Hears “Order Paid” → Deducts stock – Billing system: Hears “Order Paid” → Generates invoice – Recommendation system: Hears “Order Paid” → Updates model
Benefit: Adding new features requires no Order logic changes. Just subscribe to the event.
Three Perspectives: Real-World Stories
Story 1: The Backend Engineer’s Trap and Escape
Character: Li Ming, Backend engineer with 2 years experience at an e-commerce platform
Act 1: The Trap
Q1 2024, management decides to support “VIP Discounts.” Li Ming starts thinking through the order workflow:
Order Processing Flow: 1. User places order → calls OrderService.createOrder() 2. OrderService validates stock → calls InventoryService.checkStock() 3. OrderService calculates price → calls PricingService.calculatePrice() 4. OrderService applies discount → calls DiscountService.applyDiscount() 5. OrderService validates credit → calls CustomerService.validateCredit()
For the new requirement, Li Ming adds VIP discount logic:
// Without DDD
public class OrderService {
public Order createOrder(String customerId, List<Item> items) {
inventoryService.checkStock(items);
Money basePrice = pricingService.calculatePrice(items);
Money discountedPrice = discountService.applyDiscount(
basePrice, customerId, getCurrentPromoCode()
);
// NEW: VIP special discount
if (customerService.isVIP(customerId)) {
discountedPrice = discountedPrice.multiply(0.9);
// Extra 10% off
}
customerService.validateCredit(customerId, discountedPrice);
return orderRepository.save(new Order(...));
}
}
Seems reasonable. But 3 months later, discount rules multiply:
- VIP discounts double during promotions
- New customers get extra first-purchase discount
- Bundle deals have special pricing
- Some categories have discount caps
OrderService balloons to 500 lines. Every discount rule change risks breaking other logic. And updating becomes terrifying.
Visual Comparison: Without DDD vs With DDD
graph TD
A["Requirement: Support VIP discount doubling"]
A --> B["Modify OrderService"]
A --> C["Modify DiscountService"]
A --> D["Modify PricingService"]
A --> E["Modify CustomerService"]
A --> F["Modify InventoryService"]
B --> B1["Adjust order calculation logic"]
C --> C1["Add VIP discount rules"]
D --> D1["Modify price calculation"]
E --> E1["Modify customer validation"]
F --> F1["Inventory check logic"]
B1 --> G["❌ Problem:"]
C1 --> G
D1 --> G
E1 --> G
F1 --> G
G --> H["• Fix one place, break another
• Difficult to test all combinations
• Each change is nerve-wracking
• New requirements trigger more changes..."]
style A fill:#ffebee
style G fill:#ffcdd2
style H fill:#ff9800,color:#fff
Act 2: Awakening
Li Ming attends a DDD workshop. Realization: Why can’t Order manage its own discount logic?
With DDD:
// With DDD
public class Order {
private OrderId orderId;
private CustomerId customerId;
private List<LineItem> lineItems;
private OrderDiscount discount;
private OrderStatus status;
private List<DomainEvent> events = new ArrayList<>();
// Aggregate manages business rules
public static Order create(CustomerId customerId, List<Item> items) {
Order order = new Order(OrderId.generate(), customerId);
order.validateItems(items);
// Order validates stock itself
for (Item item : items) {
order.addLineItem(item);
}
return order;
}
// Order decides how to apply discount
public void applyDiscount(DiscountCode code, Customer customer) {
if (!code.isValid()) {
throw new InvalidDiscountException();
}
if (customer.isVIP()) {
this.discount = OrderDiscount.createVIPDiscount(code);
} else if (customer.isNewCustomer()) {
this.discount = OrderDiscount.createNewCustomerDiscount(code);
} else {
this.discount = OrderDiscount.create(code);
}
if (this.getTotalPrice().isNegative()) {
throw new InvalidDiscountAmountException();
}
}
// Place order - ensure all rules satisfied
public void place() {
if (lineItems.isEmpty()) {
throw new EmptyOrderException();
}
if (!this.status.equals(OrderStatus.DRAFT)) {
throw new InvalidOrderStatusException();
}
this.status = OrderStatus.PLACED;
this.events.add(new OrderPlacedEvent(
this.orderId, this.customerId, this.getTotalPrice()
));
}
public Money getTotalPrice() {
Money basePrice = lineItems.stream()
.map(LineItem::getPrice)
.reduce(Money.ZERO, Money::add);
if (discount != null) {
return basePrice.minus(discount.getAmount());
}
return basePrice;
}
}
With DDD: Order Aggregate Self-Manages
graph TD
A["Requirement: Support VIP discount doubling"]
A --> B["Modify Order.applyDiscount()"]
B --> C["Order aggregate validates
VIP discount rules itself"]
C --> D["✅ Problem Solved:"]
D --> E["• Only one place to change
• Complete testing within Order
• Other services completely unaffected
• Safe, controlled updates"]
style A fill:#c8e6c9
style B fill:#81c784,color:#fff
style D fill:#4caf50,color:#fff
style E fill:#a5d6a7
Now, discount rule changes only affect Order. Need VIP discounts? Update Order. Bundle discounts? Update Order. Other services? Untouched.
Plus, Order becomes documentation. New hires read Order and immediately understand “how orders work.”
Story 2: How Product Managers See the Difference
Character: Wang, E-commerce Product Manager
Without DDD: The Frustrating Conversation
Wang: “We need VIP customer special discounts.”
Li Ming (Engineer): “That’ll affect OrderService, DiscountService, PricingService, CustomerService. 2-3 weeks.”
Wang: “But it’s just discounts! Why 4 services?”
Li Ming: “Because discount logic is scattered…”
Wang: Gives up understanding. Trusts the estimate. Worries about breaking things when updating.
With DDD: The Clear Conversation
Wang: “We need VIP customer special discounts.”
Li Ming: “That logic lives entirely in Order aggregate. Update Order class only. 5 working days.”
Wang: “Why just one place?”
Li Ming: “Order aggregate owns all order business rules, including discounts. Changes only affect Order, nothing else.”
Wang: Immediately confident and understands boundaries.
Another example: New customers get 15% extra off on first purchase
Wang: “Add 15% extra discount for first-time customers.”
Li Ming: “Add Customer.isNewCustomer() check in Order.applyDiscount(). 3 days.”
Wang: “What if we break something?”
Li Ming: “Order has comprehensive unit tests. All discount combinations are covered. Tests fail if we break things, deployment stops.”
Wang sees Order’s tests:
@Test
void newCustomersShouldGetExtraFirstPurchaseDiscount() {
Order order = Order.create(customerId, items);
Customer newCustomer = new Customer(
customerId, CustomerType.NEW
);
order.applyDiscount(promoCode, newCustomer);
assertEquals(expectedDiscountAmount, order.getDiscount().getAmount());
}
@Test
void vipCustomersShouldGetHighestDiscount() {
Order order = Order.create(customerId, items);
Customer vipCustomer = new Customer(
customerId, CustomerType.VIP
);
order.applyDiscount(promoCode, vipCustomer);
assertEquals(
expectedVIPDiscountAmount,
order.getDiscount().getAmount()
);
}
Wang’s confidence soars. He sees complete business logic definition with full test coverage.
Impact Assessment Complexity Comparison
graph TD
Req["\"Add 15% extra discount for first-time customers\""]
subgraph NoDD ["❌ Without DDD"]
N1["Assess impact:"]
N2["Need to change OrderService?"]
N3["Need to change CustomerService?"]
N4["Need to change DiscountService?"]
N5["Need to change PricingService?"]
N6["...Need to change anything else?"]
N1 --> N2 --> N3 --> N4 --> N5 --> N6
Result1["😕 Wang: Why is this
so complicated?"]
N6 --> Result1
end
subgraph DD ["✅ With DDD"]
D1["Assess impact:"]
D2["Order aggregate's
applyDiscount() method"]
D3["Check customer type
isNewCustomer?"]
D4["Apply 15% discount rule"]
D1 --> D2 --> D3 --> D4
Result2["😊 Wang: Got it!
Just change Order in one place"]
D4 --> Result2
end
Req --> NoDD
Req --> DD
style Result1 fill:#ffcdd2,color:#c62828
style Result2 fill:#c8e6c9,color:#2e7d32
Story 3: The Architect’s Microservice Journey
Character: Zhang, System Architect managing single-to-microservices migration
Without DDD: The Microservices Nightmare
Company decides to split into microservices. Zhang’s plan:
- Shopping Cart Microservice
- Order Microservice
- Payment Microservice
- Recommendation Microservice
Problem: All four need Customer objects. What’s Customer?
- Cart needs: User ID, Shopping Preferences, Browse History
- Order needs: User ID, Shipping Address, Payment Method
- Payment needs: User ID, Credit Card, Risk Score
- Recommendation needs: User ID, Browse History, Purchase History
If all share one Customer object, everything breaks:
- Cart adds Shopping Preferences
- Order forced to update Customer
- Payment forced to update Customer
Customer becomes bloated, each service uses 20% of fields, maintains 100% of cruft.
With DDD: Clean Microservices
Zhang reframes using bounded contexts:
Cart BC: Cart (aggregate root)
- CartItem
- CustomerRef (ID only)
CartPreference
Order BC: Order (aggregate root)
- OrderLineItem
- ShippingAddress
- BillingInfo
- CustomerRef (ID only)
Payment BC: Payment (aggregate root)
- PaymentMethod
- RiskScore
- CustomerRef (ID only)
Recommendation BC: RecommendationProfile (aggregate root)
- BrowsingHistory
- PurchaseHistory
- CustomerRef (ID only)
Each BC owns its Customer concept. No shared databases; they communicate via events:
Cart BC publishes: – “CartCreated” → Recommendation BC updates history
Order BC publishes: – “OrderPlaced” → Inventory BC deducts stock – “OrderPlaced” → Billing BC generates invoice – “OrderPlaced” → Recommendation BC updates model
Payment BC publishes: – “PaymentSucceeded” → Order BC updates status – “PaymentFailed” → Order BC marks failure
Results:
- Independent evolution: Cart wants new fields? Only Cart changes. 2. Clear communication: Events, not API calls. Loose coupling. 3. Failure isolation: Recommendation down? Order flow unaffected. 4. Simple extension: Add points system? New BC, subscribe to events. No core changes.
Evolution From Monolith to Microservices
graph TD
A["Problem: How to separate into
multiple microservices?"]
subgraph BadWay ["❌ Wrong approach: Shared Customer model"]
B1["All services share
one Customer table"]
B2["Cart adds field
↓ All services update"]
B3["Order adds field
↓ All services update"]
B4["...Each service uses
only 20% of fields
but maintains 100% of complexity"]
B1 --> B2 --> B3 --> B4
end
subgraph GoodWay ["✅ DDD approach: Separate Bounded Contexts"]
G1["Cart BC owns
its own Customer"]
G2["Order BC owns
its own Customer"]
G3["Payment BC owns
its own Customer"]
G1 -.->|Event| EventBus["📡
Event
Bus"]
G2 -.->|Event| EventBus
G3 -.->|Event| EventBus
EventBus -.->|Subscribe| G1
EventBus -.->|Subscribe| G2
EventBus -.->|Subscribe| G3
end
A --> BadWay
A --> GoodWay
BadWay --> Result1["😞 Monolith Hell
Can't change"]
GoodWay --> Result2["😊 Microservices Heaven
Independent evolution"]
style BadWay fill:#ffebee
style GoodWay fill:#e8f5e9
style Result1 fill:#ff5252,color:#fff
style Result2 fill:#4caf50,color:#fff
Common Misconceptions
Misconception 1: DDD = Microservices
Wrong: DDD requires microservices.
Truth: DDD is a design philosophy applicable to both monoliths and microservices. A well-designed monolith using DDD beats poorly-designed microservices.
Example: A small company with just Order and Customer doesn’t need microservices. DDD monolith is simpler.
Misconception 2: DDD = Complex Framework
Wrong: DDD needs special frameworks.
Truth: DDD is pure design thinking. Order, LineItem, DiscountCode are plain Java classes. Frameworks assist, not define DDD.
Misconception 3: Every Project Needs DDD
Wrong: DDD is a silver bullet.
Truth: DDD shines with complex business logic. Simple CRUD APIs don’t benefit.
Is DDD right for you?
- Complex business logic (50+ rules/use cases) → Yes
- Multiple teams developing → Yes
- Frequent business changes → Yes
- Simple CRUD API → No
- No long-term maintenance → No
Misconception 4: DDD = Over-Engineering
Wrong: DDD makes systems complex.
Truth: DDD’s goal is simplifying complex systems. Yes, DDD requires upfront thought, but it reduces future complexity.
Comparison:
Without DDD: 100-line simple Service → 6 months later: 2000-line monster With DDD: 200-line design + structure → 6 months later: Still clear, easy to modify
Practical Guide: Getting Started
Step 1: Identify Bounded Contexts (Don’t Code Yet)
Spend 30 minutes with business people listing:
- All business roles: Customer, Support, Warehouse, Finance… 2. All main processes: Place Order, Payment, Returns, Recommendations… 3. How concepts change: What’s “Customer” in order flow? In recommendations?
Example: – Order BC: Customer = Shipping Address + Payment Method – Recommendation BC: Customer = Browse History + Preferences
Step 2: Define Ubiquitous Language
Write a short glossary ensuring everyone speaks the same language:
Order: Customer's purchase record with items, quantity, discounts, payment
DiscountCode: Promotion applicable under specific conditions
Aggregate: Boundary's guardian enforcing all business rules
Step 3: Design Core Aggregates
Start with your most complex flow. For orders, Order is the aggregate containing:
- OrderLineItem (child)
- OrderDiscount (child)
- All validation rules
Step 4: Test-First, Then Code
@Test
void orderShouldValidateDiscountCodeValidity() {
Order order = Order.create(customerId, items);
InvalidDiscountCode code = new InvalidDiscountCode();
assertThrows(
InvalidDiscountException.class,
() -> order.applyDiscount(code, customer)
);
}
@Test
void vipCustomersShouldGetExtraDiscount() {
Order order = Order.create(customerId, items);
DiscountCode code = new DiscountCode("VIP2024");
Customer vipCustomer = new Customer(
customerId, CustomerType.VIP
);
order.applyDiscount(code, vipCustomer);
Money expectedDiscount = basePrice.multiply(0.2);
// 20% off
assertEquals(
expectedDiscount,
order.getDiscount().getAmount()
);
}
With clear tests, business logic crystallizes. Then implement Order to pass.
Complete Workflow Example
Order Processing DDD Flow
sequenceDiagram
actor User as User
participant OrderBC as Order BC
Aggregate Root
participant InventoryBC as Inventory BC
participant BillingBC as Billing BC
participant RecommendBC as Recommend BC
User->>OrderBC: Place order
Order.create()
activate OrderBC
OrderBC->>OrderBC: ✅ Validate stock
✅ Validate discount
✅ Validate credit
OrderBC->>OrderBC: Publish event
OrderPlaced
deactivate OrderBC
Note over OrderBC: Order as complete
business rule guardian
All validation here
OrderBC->>InventoryBC: OrderPlaced event
OrderBC->>BillingBC: OrderPlaced event
OrderBC->>RecommendBC: OrderPlaced event
par Parallel async processing
activate InventoryBC
InventoryBC->>InventoryBC: Listen OrderPlaced
Async deduct stock
InventoryBC-->>User: Stock deducted
deactivate InventoryBC
and
activate BillingBC
BillingBC->>BillingBC: Listen OrderPlaced
Async generate invoice
BillingBC-->>User: Invoice generated
deactivate BillingBC
and
activate RecommendBC
RecommendBC->>RecommendBC: Listen OrderPlaced
Async update recommendations
RecommendBC-->>User: Recommendations updated
deactivate RecommendBC
end
DDD’s Core Value
graph LR
subgraph Before ["Without DDD
Scattered Logic"]
B1["Service A"]
B2["Service B"]
B3["Service C"]
B4["Service D"]
B5["Service E"]
B_logic["❌ Business rules
scattered everywhere"]
B1 --- B2
B2 --- B3
B3 --- B4
B4 --- B5
end
subgraph After ["With DDD
Centralized Rules"]
A1["Aggregate Root
🏛️"]
A_logic["✅ Business rules
centrally managed"]
A2["Other services"]
end
Before -->|Introduce DDD| After
style B_logic fill:#ff5252,color:#fff
style A_logic fill:#4caf50,color:#fff
Conclusion
DDD isn’t a silver bullet, but for complex business systems, it’s the right medicine. Its core promise: through clear boundaries and well-defined roles, transform complexity into simplicity.
Key takeaways:
- Aggregates centralize logic: Changes don’t ripple through the system 2. Bounded contexts clarify ownership: Different domains own their concepts 3. Domain events enable loose coupling: New features need no core changes 4. Ubiquitous language unifies communication: Everyone speaks the same language
Your next step: Pick your most complex flow, redesign it with DDD. You’ll discover scattered logic becomes clear, changes become safe.