Using Domain-Driven Design to Evolve from Anemic Code Toward High Cohesion and Low Coupling

When I started exploring Domain-Driven Design (DDD) in 2019, I found many tutorials focused heavily on strategic design, often surrounded by obscure concepts that made them difficult to read. Some explained the concepts of DDD without diving into the reasons for adopting such an approach or the benefits it brings, which creates real challenges in practice. In some cases, teams forcibly introduce a DDD architecture into a project without understanding the motivation, only to add unnecessary complexity and hidden risks.

To address this, I want to start from the basics of code and explain step by step how to apply DDD ideas to real-world development. In this discussion, we focus on using DDD to organize our code so that it achieves the goal of high cohesion and low coupling.

Let's look at a code snippet for an order placement feature in an e-commerce system:

@Autowired
ProductDao productDao;
@Autowired
UserDao userDao;
public void createOrder(String productId,String userId,int count){
     Product product = productDao.queryById(productId);
     UserInfo user=userDao.queryByUserId(userId);
     
     //风险检测
     RiskResponse riskRes= riskClient.queryRisk(xxx);
     if(riskRes!=null&&riskRes.getCode()==0&&riskRes.getRiskCode().equal("0001")){
       //命中风控
          throw new BizException("下单失败,请检查网络")
     }
  
     Order order=new Order();
     order.setOrderId(IdUtils.generateId());
     order.setPrice(product.getPrice()*count);
     order.setAddress(user.getAddress());
     order.setStatus(OrderEnum.OrderSucess);
     orderDao.insert(order);
  
     //预热缓存和增加记录
     redisService.set("Order:OrderID_"+order.getOrderId(),order);
     orderLogDao.addLog(order);
       
      MessageEntity message = new MessageEntity();
      message.setOrderId(order.getOrderId());
      message.setMessage("下单成功");
      kafkaSender.sent(messageEntity);

}

Code Analysis

We can break down the logic into five steps:

  1. Query product and user information.
  2. Perform risk control checks for the order.
  3. Create the order and persist it.
  4. Write to cache and log the order.
  5. Send a success message via Kafka to notify other systems.

Based on business importance, we can separate these steps into core and non-core business. Clearly, the order creation and its directly related operations (steps 1, 2, 3) belong to the core domain, while caching, logging, and sending a Kafka message are supporting, non-core activities.

Core Code Analysis

1. Querying product and user information

The classes productDao and userDao encapsulate CRUD operations against the database. The problem is that their method implementations are tightly coupled to a specific storage medium, which means the business logic is strongly dependent on how data is stored.

For example, if we currently use MySQL, the methods inside userDao and productDao wrap SQL statements. If we later switch to a different data access framework or migrate from MySQL to Elasticsearch, we must modify the implementations of these DAO classes. Such changes directly impact the core business code and introduce uncertainty across many areas of the project, making every optimization a cautious exercise.

This tight coupling not only raises maintenance difficulty but also increases the fragility of the system. When the storage layer needs to change, we must find and modify every piece of code that depends on it, which is time-consuming and error-prone.

2. Risk control check in the order flow

During order creation we invoke a third-party risk query interface. This dependency forces us to carefully handle the returned value: checking whether it is null, verifying the success status, and validating the business codes, just to ensure robustness.

However, this dependency also means that our core logic may need to change when the third-party interface changes. Suppose the risk interface introduces new business codes such as 0002 and 0003 to represent new risk scenarios. The core code would then need to be adjusted like this:

riskRes!=null&&riskRes.getCode()==0&&(riskRes.getRiskCode().equal("0001")||riskRes.getRiskCode().equal("0002")||riskRes.getRiskCode().equal("0003"))

Such changes make the code harder to read and maintain. Every interface change forces similar modifications in multiple places, causing a ripple effect that increases coupling and degrades maintainability.

3. Creating and persisting the Order

Persistence issues were already covered above, so we won't repeat them.

When observing the order creation process, we find it is a critical part of core business. However, what we truly need is the result of the creation. Placing the entire construction logic right inside the core business code harms readability.

Many developers might suggest extracting the order creation into a separate component to reduce the coupling. That is indeed a reasonable solution, and many DDD implementations follow that path. I personally prefer to use a factory pattern for entities to further decouple the creation process.

Consider these two assignment operations during order creation:

order.setOrderId(IdUtils.generateId());
order.setPrice(product.getPrice() * count);

These statements carry deeper business meaning. Generating the order id uniquely identifies each order and ensures correctness; calculating the price based on product price and quantity guarantees monetary accuracy.

In a traditional anemic domain model, these implicit business intents are not explicitly defined. When similar business logic is scattered across different classes and services, the code becomes fragmented and cannot form a cohesive whole. Searching for references and making changes in multiple places increases maintenance cost and complexity.

Non-Core Code Analysis

In Domain-Driven Design, we often split a system into three main areas: Core Domain, Generic Domain, and Supporting Domain.

  1. Core Domain: Represents the heart of the business, including core rules and processes. In this example, the order placement action and its required data belong here.
  2. Generic Domain: Contains cross-cutting business logic, such as caching, logging, and notifications. Here, writing to cache, writing the order log, and sending a notification are generic domain activities.
  3. Supporting Domain: Includes infrastructure and common utilities such as database access, network communication, error handling, etc.

There is no fixed rule for how to split these. It depends on specific business needs. In this example, because the order placement and its directly dependent data are core while caching, logging, and messaging belong to other domains, we should split them using domain interactions. The order action stays in the core domain, while the other operations are invoked through domain events or similar mechanisms. This preserves cohesion in the core domain and reduces coupling with other domains.

Code Optimization and Domain-Driven Design

Problem Summary

Based on the discussion above, the existing code suffers from several issues:

  1. Third-party interface details intrude into the core business, causing frequent changes and reducing stability and maintainability.
  2. Business logic is tightly coupled with data storage, making it hard to reuse logic or switch storage backends, which limits scalability.
  3. Non-core activities are mixed into the core business, decreasing readability and requiring readers to filter out irrelevant details.
  4. Entity business logic is scattered across different pieces of code, making it fragmented and difficult to maintain.
  5. Strong coupling between domains means that changes in other domains can easily produce unintended side effects in the core logic, increasing system fragility and change risk.

Optimization in Practice

To address these issues, we introduce DDD concepts to optimize the core business code and separate generic business logic, producing cleaner and more maintainable code. Below are the optimization approach and concrete implementations:

1. Adapter Pattern to Isolate Third-Party Interfaces

The original code directly calls a risk query interface that may evolve. We use the Adapter pattern to pull the third-party call out of the core business code. A RiskCheckAdapter class encapsulates the risk query logic and translates the response into the ubiquitous language of the domain. The core business now only cares about whether the risk check passed, without worrying about the exact return values.

@Service
public class RiskCheckAdapter {
    @Autowired
    private RiskClient riskClient;
    public boolean isRiskDetected(String productId, String userId) {
        RiskResponse riskRes = riskClient.queryRisk(xxx); // parameters according to reality
        return riskRes != null && riskRes.getCode() == 0 && riskRes.getRiskCode().equals("0001");
    }
}

@Service
public class RiskCheckService {
    @Autowired
    private RiskCheckAdapter riskCheckAdapter;

    public boolean isRiskCheckPassed(String productId, String userId) {
        return riskCheckAdapter.isRiskDetected(productId, userId);
    }
}

2. Repository Pattern to Decouple Data Access

To solve the tight coupling between core business and data storage, we introduce the Repository pattern. By creating abstract repository interfaces and concrete implementations, we decouple core business from data access. If the storage medium changes, only the repository implementation needs to be modified, not the core business code.

// IProductRepository.java
public interface IProductRepository {
    Product findById(String productId);
}

// IUserRepository.java
public interface IUserRepository {
    User findById(String userId);
}

// IOrderRepository.java
public interface IOrderRepository {
    void save(Order order);
}

// IOrderLogRepository.java
public interface IOrderLogRepository {
    void addLog(OrderLog log);
}

// ProductRepository.java
@Repository
public class ProductRepository implements IProductRepository {
    @Autowired
    private ProductDao productDao;

    @Override
    public Product findById(String productId) {
        return productDao.queryById(productId);
    }
}

// UserRepository.java
@Repository
public class UserRepository implements IUserRepository {
    @Autowired
    private UserDao userDao;

    @Override
    public User findById(String userId) {
        return userDao.queryByUserId(userId);
    }
}

// OrderRepository.java
@Repository
public class OrderRepository implements IOrderRepository {
    @Autowired
    private OrderDao orderDao;
    @Autowired
    private OrderConverter converter;

    @Override
    public void save(Order order) {
        OrderPO orderPO = converter.toPO(order);
        orderDao.insert(orderPO);
    }
}

// OrderLogRepository.java
@Repository
public class OrderLogRepository implements IOrderLogRepository {
    @Autowired
    private OrderLogDao orderLogDao;

    @Override
    public void addLog(OrderLog log) {
        orderLogDao.addLog(log);
    }
}

After this refactoring, we can compare the dependency changes. The service layer no longer directly depends on the DAO implementations.

Before and after dependency comparison

3. Rich Domain Model to Consolidate Entity Logic

By encapsulating domain logic within entities, we improve cohesion and readability. In the original anemic model, the order's business logic was scattered. Now we use a rich domain model, keeping order creation and property assignment inside the entity itself.

public class Order {
    private String orderId;
    private int quantity;
    private double totalAmount;
    private String address;
    private int status;

    public void create(Product product, User user, int quantity) {
        this.address = user.getAddress();
        this.status = OrderStatus.SUCCESS;
        this.generateOrderId();
        this.computeTotal(product.getPrice(), quantity);
    }

    private void generateOrderId() {
        this.orderId = IdGenerator.generate();
    }

    private void computeTotal(double unitPrice, int quantity) {
        this.quantity = quantity;
        this.totalAmount = unitPrice * quantity;
    }

    // ... other properties and methods
}

public class OrderFactory {
    public static Order createOrder(Product product, User user, int quantity) {
        Order order = new Order();
        order.create(product, user, quantity);
        return order;
    }
}

// Usage
Order order = OrderFactory.createOrder(product, user, count);
orderRepository.save(order);

4. Domain Events to Decouple Inter-Domain Communication

For communication between domains, we introduce domain events. By defining domain events, event listeners, and a publishing mechanism, interactions across domains become loosely coupled. When an order is successfully created, we simply publish an order-created event; other domains react accordingly, reducing interdependency.

Domain Event Overview

Event handling code:

// CommonEventListener.java
@Service
public class CommonEventListener {

    @Autowired
    private IOrderLogRepository orderLogRepository;

    @Autowired
    private RedisCacheService cacheService;

    @Autowired
    private KafkaPublisher kafkaPublisher;

    @EventListener
    public void handleOrderCreatedEvent(OrderCreatedEvent event) {
        String orderId = event.getOrderId();
        Order order = loadOrderFromRepository(orderId);

        // Process the event within the domain object
        order.processOrderCreatedEvent(orderLogRepository, cacheService);
        publishMessage(order);
    }

    private Order loadOrderFromRepository(String orderId) {
        // Retrieve order details from repository using orderId
        // Return the Order object
        return null;
    }

    private void publishMessage(Order order) {
        MessageEntity message = new MessageEntity();
        message.setOrderId(order.getOrderId());
        message.setMessage("Order placed successfully");
        kafkaPublisher.send(message);
    }
}

Optimized Core Business Code

After the above optimizations, the core business code becomes clearer and more maintainable. Here is the final version of the order creation process:

// OrderServiceImpl.java
@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    private ApplicationEventPublisher eventPublisher;
    @Autowired
    private IProductRepository productRepository;
    @Autowired
    private IUserRepository userRepository;
    @Autowired
    private IOrderRepository orderRepository;
    @Autowired
    private RiskCheckService riskCheckService;

    @Override
    public Order createOrder(String productId, String userId, int quantity) {
        Product product = productRepository.findById(productId);
        User user = userRepository.findById(userId);

        boolean passed = riskCheckService.isRiskCheckPassed(productId, userId);
        if (!passed) {
            throw new BizException("Order creation failed, please check your network.");
        }

        Order order = OrderFactory.createOrder(product, user, quantity);
        orderRepository.save(order);

        eventPublisher.publishEvent(new OrderCreatedEvent(order.getOrderId()));
        return order;
    }
}

Summary

Through the lens of Domain-Driven Design, we successfully optimized the original code. By introducing the Adapter pattern, the Repository pattern, a rich domain model, and domain events, we achieved code that is cleaner, more readable, and easier to maintain. These optimizations not only stabilize the core business but also provide stronger support for future extensions and changes.

In the next discussion, we will explore how to evolve DDD within a project architecture and provide a concise project framework as an example. Thank you for reading.

Tags: Domain-Driven Design High Cohesion Low Coupling Repository Pattern Adapter Pattern

Posted on Sun, 26 Jul 2026 17:05:43 +0000 by dragonusthei