Why Does One Business Rule Change Break 5 Services? How DDD Solves This

🌏 閱讀中文版本


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:

  1. 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:

  1. 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:

  1. 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.

Leave a Comment