跪拜 Guibai
← Back to the summary

Frontend DDD Stops Small Feature Requests From Wrecking Your Codebase

Background

Everyone has likely experienced the scenario above: a product manager or backend developer asks you to help confirm a piece of historical logic. For systems with complex business rules, we need to unravel the code layer by layer. Of course, with AI assistance now, we can use "magic" to quickly locate logic. But if we need to continue iterating on top of this historical logic, even with AI's help, it's easy to fall into an irreversible cycle—changes become impossible to push forward, or the code becomes too difficult to self-review after changes.

As a project evolves, the escalation of business complexity is inevitable, but code becoming unmaintainable ultimately stems from the loss of control over project complexity.

What Makes Our Code Unmaintainable?

Example 1

Let's look at a piece of pseudocode first. Below is a shopping cart list page with a simple function: submit selected cart line items.

function CartList({ data }: { data: CartLineData[] }) {
  const [cart] = useState(() => new Cart(data));
  const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  const handleSubmit = async () => {
    try {
      setLoading(true);
      await cartService.submit({ products: selectedCodes });
    } catch (error) {
      Toast.show(error.message);
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <ScrollList>
        {cart.lines.map(line => (
          <CartLine
            key={line.productCode}
            line={line}
            checked={selectedCodes.includes(
              line.productCode
            )}
            onCheck={checked => {
              setSelectedCodes(current =>
                checked
                  ? [...current, line.productCode]
                  : current.filter(
                      code =>
                        code !== line.productCode
                    )
              );
            }}
          />
        ))}
      </ScrollList>

      <Button loading={loading} onClick={handleSubmit}>
        Submit
      </Button>
    </>
  );
}

The current code logic looks quite clear. Then the product manager proposes a new requirement: we need to automatically group submitted products by type for submission. If they are daily-delivery products, they should be grouped together for submission.

import React, { useState, useMemo } from 'react';

function CartList({ data }: { data: CartLineData[] }) {
  const cart = useMemo(() => new Cart(data), [data]);
  const [selectedCodes, setSelectedCodes] = useState<string[]>([]);
  const [loading, setLoading] = useState(false);

  const totalAmount = useMemo(() => {
    return cart.lines
      .filter(line => selectedCodes.includes(line.productCode))
      .reduce((sum, line) => {
        const lineTotal = line.totalAmount ?? (line.price * line.quantity);
        return sum + lineTotal;
      }, 0);
  }, [cart, selectedCodes]);

  const handleSubmit = async () => {
    if (selectedCodes.length === 0) {
      Toast.show('Please select products to settle first');
      return;
    }

    try {
      setLoading(true);

      // 1. Get the array of selected complete product objects
      const selectedItems = cart.lines.filter(item => 
        selectedCodes.includes(item.productCode)
      );

      // 2. Classify by item.isDaily
      const dailyProducts = selectedItems.filter(item => item.isDaily);
      const normalProducts = selectedItems.filter(item => !item.isDaily);

      // 3. Assemble the products structure as required
      // (If a group has no products, usually filter out empty groups. If the backend strictly requires both groups, remove the filter)
      const productsPayload = [
        {
          groupType: 'daily',
          products: dailyProducts.map(item => item.productCode),
        },
        {
          groupType: 'normal',
          products: normalProducts.map(item => item.productCode),
        },
      ].filter(group => group.products.length > 0);

      // 4. Submit data
      await cartService.submit({ products: productsPayload });
    } catch (error: any) {
      Toast.show(error?.message || 'Submission failed, please retry');
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <ScrollList>
        {cart.lines.map(line => (
          <CartLine
            key={line.productCode}
            line={line}
            checked={selectedCodes.includes(line.productCode)}
            onCheck={checked => {
              setSelectedCodes(current =>
                checked
                  ? [...current, line.productCode]
                  : current.filter(code => code !== line.productCode)
              );
            }}
          />
        ))}
      </ScrollList>

      <span>Estimated Amount: ¥{totalAmount.toFixed(2)}</span>

      <Button loading={loading} onClick={handleSubmit}>
        Submit
      </Button>
    </>
  );
}

As you can see, just a small requirement change causes the visual complexity and actual logical complexity of the code to skyrocket. If the product manager adds another requirement at this point—determine whether daily-delivery and regular products should be merged based on the recommended delivery date, and show a prompt dialog—following our intuitive approach, we would continue stacking new judgments and interactions on top of the existing daily-delivery logic. The chaotic result after the change is predictable.

With just these fields now, to understand the logic, one must read through the entire component. As business accumulates, unmaintainability becomes inevitable. The relationships between fields become intertwined, and we helplessly fall into a situation where "pulling one hair moves the whole body."

Example 2

Another example: everyone has likely experienced refactoring—whether component function refactoring or overall project refactoring—which inevitably involves sorting out business logic and strategies. Business logic and strategies are usually stable, while UI interactions have higher flexibility. But in actual development, a piece of logic like "determine whether a product is valid" is often repeatedly written in multiple components. There is no constant business entity in the code to carry the logic.

Problem Summary

Faced with these problems, our intuitive countermeasures are: abstract utility functions, or enhance extensibility when designing components. But a single requirement change from the product side can easily shatter the "ingenious design" we once thought we had. Why?

Because these countermeasures are merely designs at the technical level. Imagine if we just keep stacking technical designs—although we can easily extract code with the same style into components and define more generic classes, these components and classes cannot fully express the business. Because the technical model does not match the business domain model, when the business changes, the technical model needs to be redesigned. Over time, we easily fall into a death loop of "redesign -> design destroyed by business -> redesign again".

Therefore, we need to establish a model in the frontend that can express the "business language."

Expression at the business level requires our code to match business abstractions and strategies. Ideally, code can be expressed as a requirements document. Domain-Driven Design in the frontend is a solution to the problem of how to use code to express business-level knowledge and logic.

What is Domain-Driven Design (DDD)?

Domain-Driven Design (DDD) is a model-driven design approach.

In summary, the domain model is a unified language that can connect product, backend, and frontend. A unified language can both unify everyone's understanding and facilitate the accumulation of business knowledge later. It reduces ambiguity in collaboration and redundant expression.

How to Do Domain-Driven Design in the Frontend

In the past, domain models were often led by backend design. How should the frontend implement DDD? We recently happened to be refactoring a system. Below is a summary of the process for building a domain model during refactoring:

  1. Understand requirements: This is the foundation for building our model.
  2. Understand backend model design: The backend is usually responsible for server-side business constraints and data consistency, and its model is an important reference for frontend model construction. However, the frontend should not directly copy interface DTOs; it should establish its own domain model combining product language and frontend scenarios, and isolate differences through Mappers.
  3. Analyze associations, find aggregate boundaries and aggregate roots: In business, domain models have associations (e.g., products and shopping carts).
  4. Establish frontend domain model: Build business entities in the frontend code.
  5. Control interface contracts: Build anti-corruption isolation in the interface layer.
  6. Pay attention to business definitions during development: Synchronize and align with product and backend models in real-time.

Among these, the first three processes are the most important, because they directly determine the rationality of the subsequent model establishment.

Establishing the Frontend Domain Model

From Anemic Model to Rich Model

In DDD, the rich model (rich domain model) and the anemic model represent two completely different object-oriented design philosophies. Their core difference lies in: whether "business data" and "business behavior (logic)" are encapsulated in the same object.

In our refactoring practice, the frontend domain model we designed is a behavior-rich domain object (i.e., a rich model). It not only purely expresses data structures but also needs to leave enough room for page interactions.

Based on the previous example, we can first establish such an anemic model.

/** Shopping Cart Line */
class CartLine {
  /** Cart Line ID */
  cartLineId: string;
  /** Store Code */
  shopCode: string;
  /** Product Code */
  productCode: string;
  /** Quantity */
  quantity: number;
  /** Display Name */
  displayName: string;
  /** Main Image */
  mainImage: string;
  /** Specification Text */
  specText?: string;
  /** Current Sale Unit Price */
  salePrice: number;
  /** Line Amount */
  lineAmount: number;
  /** Whether Daily Delivery is Supported */
  supportDailyDelivery: boolean;
}

This model can provide type constraints, but it only answers "what data does a cart line have?" It does not answer:

To avoid this knowledge being scattered across components, Hooks, and utility functions, we can establish the following rich model:

export interface CartLineData {
  cartLineId: string;
  productCode: string;
  displayName: string;
  quantity: number;
  salePrice: number;
  saleStep: number;
  minBuyQuantity: number;
  effective: boolean;
  supportDailyDelivery: boolean;
}

export class CartLine {
  constructor(private readonly data: CartLineData) {}

  get quantity() {
    return this.data.quantity;
  }

  get amount() {
    return this.data.salePrice * this.data.quantity;
  }

  get isDailyDelivery() {
    return this.data.supportDailyDelivery;
  }

  get canSubmit() {
    return this.data.effective && this.validateQuantity().length === 0;
  }

  validateQuantity(): string[] {
    ....
    // Contains some validation logic
  }

  changeQuantity(quantity: number) {
    return new CartLine({ ...this.data, quantity });
  }
}

Interface Model Adaptation

We have defined the model on the frontend, but backend interfaces will change as the business iterates. The returned structure and field naming often cannot be used out of the box (e.g., there are redundant fields). At this point, we can establish a Mapper for the shopping cart entity, which acts as an anti-corruption layer. When backend fields change, modifications are concentrated in the Mapper, and the domain layer and view layer continue to use stable business language.

export const toCartLine = (dto: CartLineDTO) =>
  new CartLine({
    cartLineId: String(dto.cartLineId),
    productCode: dto.productCode,
    displayName: dto.displayName,
    quantity: dto.quantity,
    salePrice: dto.salePrice,
    saleStep: dto.saleStep,
    minBuyQuantity: dto.minBuyQuantity,
    effective: dto.effective,
    supportDailyDelivery: dto.supportDailyDelivery,
    ...
  });

Domain Services

During model design, you might encounter this problem: you want to model a domain concept, but it's not suitable to put it on an entity, nor on a value object. At this point, you might doubt whether your modeling approach is wrong. Don't worry, domain services are specifically for handling such scenarios.

Continuing the example above, as the project evolves, our strategies will gradually become more complex. Suppose we have a strategy like the one in the table below; we can create a SplitStrategyService to specifically handle this.

// Method 1: Can be written as a pure function domain service
export const SplitStrategyService = (
  cartLines: CartLine[],
  recommendation: DeliveryDateRecommendationInfo | undefined,
  canDailyDelivery: boolean
) => {
 const hasDailyProduct =
   canDailyDelivery &&
   cartLines.some(line => line.isDailyDelivery && line.canSubmit);

 const hasNormalProduct = cartLines.some(
   line => !line.isDailyDelivery && line.canSubmit
 );

  if (!hasDailyProduct || !hasNormalProduct) {
    return DailyDeliveryStrategy.NONE;
  }

  const { commonDate, dailyDate } = getRecommendDeliveryDatePair(recommendation);

  if (commonDate.isSame(dailyDate, 'day')) {
    return DailyDeliveryStrategy.UNIFIED;
  }

  if (commonDate.isAfter(dailyDate)) {
    return DailyDeliveryStrategy.SEPARATE;
  }

  return DailyDeliveryStrategy.NONE;
};

// Method 2: Or extend aggregation capabilities through a class factory
export class Cart {
  constructor(protected readonly cartLines: CartLine[]) {}
  ...
}

export class SplitStrategyService {
  static extend(BaseCart: typeof Cart) {
    return class SplittableCart extends BaseCart {
      decideSplitStrategy(
        recommendation: DeliveryDateRecommendationInfo | undefined,
        canDailyDelivery: boolean
      ): DailyDeliveryStrategy {
        ....
        return DailyDeliveryStrategy.NONE;
      }
    };
  }
}

There is no standard paradigm for domain services. If there are only a small amount of domain service contents, pure functions are actually more intuitive.

The multiple if statements that originally required reading through the entire page to understand are transformed into clear and intuitive business sentences:

// -----Usage 1-----
const strategy = decideSplitStrategy(
  cartLines,
  recommendation,
  shop.canDailyDelivery
);
// -----Usage 2-----
const SplittableCart =
  SplitStrategyService.extend(Cart);

const cart = new SplittableCart(cartLines);

const strategy = cart.decideSplitStrategy(
  recommendation,
  shop.canDailyDelivery
);

What is the difference between Domain Services and Utils?

Syntactically, both might just be pure functions. The difference lies in the knowledge they express.

Type Focus Example
Utility Function General technical capability Price formatting, title assembly, etc.
Domain Service Business rules and strategies decideDailyDeliveryStrategy, validation on business submissions, etc.

I have summarized three small methods to judge whether a function should enter the domain layer:

  1. Does it contain stable business rules and strategies, rather than UI or framework details?
  2. Does it involve multiple entities and cannot naturally be placed into a single entity?
  3. If you were to refactor this piece of code logic again, is this business logic you must pay attention to?

If the answers are mostly "yes," it is more appropriate to express it in a domain service.

Interaction Model

In this article, the object that carries the execution process of a single user use case is defined as the "Interaction Model." In traditional DDD layering, it is closer to an application service. It is responsible for connecting the View, Domain Model, and Infrastructure.

Above, we have successfully defined entities and implemented domain services, solving the problem of "where to put domain knowledge and business rules." But we haven't solved:

Why is an Interaction Model Needed?

Take the order submission page as an example:

Enter Page
↓
Request delivery information (including delivery date, route, etc.)
↓
Request shopping cart product information
↓
User selects products
↓
Calculate price
↓
Click submit
↓
Validate business rules
↓
Generate submission parameters
↓
Call API

Without interaction, the page component would bear all responsibilities.

Building the Interaction Model

In a React project, the interaction model can usually be represented as a business Hook.

For the above process, we can write it as: including initialization and submission. Of course, if our page has more complex functionality, such as many operations, we can split out a dedicated action-hook to handle the expression of operations.

function useSettlementInteraction(){
 const [orders,setOrders] = useState([])
 const initialize = async()=>{
    const [
      cart,
      delivery,
      promotion
    ] = await Promise.all([
      fetchCart(),
      fetchDelivery(),
      fetchPromotion()
    ])
 }
 const submit = async()=>{
    const strategy =
       decideDeliveryStrategy(
          orders
       )
    const params =
       createSubmitParams({
          orders,
          strategy
       })
    await submitOrder(params)
 }
 return {
    orders,
    initialize,
    submit
 }
}

Usage in the View Layer

At this point, everyone might have a doubt: we have written so much code logic, but haven't written a single line of view code yet. But this is precisely the necessary path to liberate business logic from the view.

Earlier, we expressed business knowledge through the domain model, carried cross-entity strategies through domain services, and organized complete user use cases through the interaction model. So there is one last question: what should the View layer still be responsible for?

Ideally, a page should only care about:

Still practicing with the previous example, we can now get a flat view layer. Doesn't it look clean and neat:

As you can see, the page does not know the business details.

In the view layer, we are merely calling the well-written interaction model. The relevant business knowledge has been hidden in the domain model.

function SettlementPage(){
  const {
    orders,
    loading,
    submit
  } = useSettlementInteraction();

  return (
    <>
      <OrderList 
        data={orders}
      />

      <Button
        loading={loading}
        onClick={submit}
      >
        Submit Order
      </Button>
    </>
  )
}

Summary

Responding to the Foreword

Responding to the foreword: Going back to the two pain points raised at the beginning of the article—business logic and interaction logic intertwined and difficult to maintain, and the hardship of sorting out business logic during refactoring. By now, both problems have clear solutions:

  1. Business changes no longer affect the view: The domain model expresses the essence of the business, and the domain layer has been separated from the view layer. When business changes occur later, we only need to update the knowledge in the domain layer, reducing cascading modifications to the view layer.
  2. Multi-platform reuse and framework-agnostic: After the domain model is extracted from the view, whether it's business refactoring or multi-platform requirements (e.g., different views for PC and APP), the domain layer code can be directly reused across multiple platforms and different frameworks.

Domain Model Design is Not a Silver Bullet

In the world of Xianxia, we often see divine abilities like "one sword breaks ten thousand laws." However, in reality, designs often have their applicable scope and limitations. What Domain Model Driven Design solves is the problem of uncontrolled business complexity, not a universal solution for all code organization problems.

For some simple scenarios, introducing a complete domain model might actually increase costs. For example:

Secondly, Domain Model Driven Design is a mindset. Even if we implement this mindset in a brand new project, it doesn't mean everything is settled. Business iteration is a long-term process, and during this process, our model also needs corresponding iteration. How to maintain our design well, prevent dirty code from polluting our model, and set an appropriate admission standard is also very important.

Finally, I want to say that frontend Domain-Driven Design does not require everyone to rigidly apply file templates, nor must you create a domain class filled with details. The only standard for architectural design is: whether you have embodied the idea of the domain layer and clearly expressed the business entities.

For where to place a piece of logic in daily development, I have summarized a simple classification standard:

  1. View Layer: Pure view interactions like loading state, dialog display control, write directly in the component.
  2. Interaction Model Layer: Use case orchestration content such as data requests for page initialization and process validation, write into the corresponding Hook.
  3. Domain Layer: Business-strongly-related logic (such as store status judgment), order splitting strategies, write all into the domain layer. It is worth noting that this layer must be decoupled from the framework.