How DDD and Hexagonal Architecture Transform a CRM System – A Practical Walkthrough

This article explores the practical application of Domain‑Driven Design and hexagonal architecture in a real‑world CRM system, detailing strategic and tactical design, module division, code examples, and a step‑by‑step migration from a traditional layered architecture to a more flexible, maintainable backend solution.

Huolala Tech
Huolala Tech
Huolala Tech
How DDD and Hexagonal Architecture Transform a CRM System – A Practical Walkthrough

Preface

In the fast‑growing internet industry, the relationship between business needs and technical architecture becomes increasingly tight. Maintaining system stability, flexibility, and scalability in a complex business environment is a major challenge for technical teams.

This article dives into the application of DDD (Domain‑Driven Design) in the architecture and coding practice of the Huolala user CRM system, sharing architectural patterns, key technical points, and the implementation transformation process to provide reference for technical teams.

Note: Due to length limits, this article focuses on practice; readers should consult DDD basics separately.

1. What is DDD?

Before we start, we assume readers have some understanding of DDD and can skip this section if desired.

DDD is often associated with large, complex systems, and its abstract concepts can seem daunting. In fact, DDD is a software design mindset that applies throughout a software lifecycle, regardless of system size. Concepts such as bounded contexts, entities, value objects, and repositories exist in all systems.

DDD offers two design levels: strategic design, which guides business domain division and overall model design, and tactical design, which focuses on code and technical architecture, aligning the technical model with the business model.

We can view the problem space (business) and solution space (technical) as two separate realms.

Strategic design extracts sub‑domains and bounded contexts via a context map; tactical design implements the solution space using a domain language.

Note: This practice mainly covers tactical design, not strategic design.

What DDD Brings

Clear model boundaries : DDD mirrors real‑world business models, making the software more maintainable and extensible.

Better enterprise architecture : According to Conway's law, a well‑structured domain division leads to a clearer organizational structure.

Agile, iterative, and continuous modeling : DDD follows agile practices, reducing the impact of continuous iteration on maintainability.

Ultimately, DDD transforms business models into software models, making the software more aligned with complex, changing business, reducing maintenance costs, and increasing flexibility.

2. Architecture Evolution – Hexagonal Architecture

As Robert C. Martin says, a good architecture keeps the cost of change low throughout the system's lifecycle. DDD tactical design promotes architectural patterns that control change cost.

A good architecture should be independent of underlying tools and allow users to choose tools freely while focusing on business use cases.

We will now look at the evolution of DDD architecture.

2.1 Layered Architecture

Initially, many layered architectures enforce strict layer responsibilities, but over iterations they tend to become loosely layered, with unclear responsibilities. For example, controllers may directly access DAOs, and services become tangled with remote calls and repository logic, leading to maintenance challenges.

2.2 DDD Architecture Model

To separate business logic from technical implementation, DDD introduces a domain layer for business logic aggregation and applies the Dependency Inversion Principle, making the framework more extensible.

In practice, we often have a partial inversion where only the infrastructure layer (e.g., repositories, remote services) depends on the domain layer.

2.3 Hexagonal Architecture

Hexagonal (or Clean/Onion) architecture flattens the layered model using the Dependency Inversion Principle, adding symmetry. New clients only need an inbound adapter and a corresponding outbound adapter; adapters translate client input to API parameters, and output can be realized in various ways.

Hexagonal architecture follows the clean‑architecture dependency rule: changes in outer layers do not affect inner layers. Replacing a database with another component does not impact business logic.

3. Coding Practice

3.1 Architecture and Module Division

The project follows the hexagonal architecture and includes the following modules:

starter : UI layer (controller, provider, task, consumer)

application : Application service layer (lightweight service layer, only orchestrates business logic)

domain : Domain layer (aggregates business logic, domain services, domain models)

infra-repository : Infrastructure – repository layer (DAO, persistence)

infra-remote : Infrastructure – remote service layer (external calls)

shared : Common components (constants, utilities, cache, interceptors, configuration)

3.1.1 Comparison with Traditional Three‑Layer Architecture

Key improvements: business orchestration, dependency inversion, plug‑in components.

Business orchestration : Application service layer handles orchestration; domain layer implements business logic.

Dependency inversion : Domain layer is the innermost layer, independent of external components.

Plug‑in components : Infrastructure interfaces are defined in the domain; implementations can be swapped without changing business code.

3.1.2 Design Principles

Domain model aggregation follows the Common Closure Principle .

Business logic aggregation follows the Common Reuse Principle .

Infrastructure components are plug‑in, following the Stable Dependencies Principle .

Infrastructure depends on stable abstractions, adhering to the Stable Abstractions and No Cyclic Dependencies Principle .

3.2 Mall Order Business Coding Practice

We use a typical e‑commerce order scenario to demonstrate DDD tactical design.

3.2.1 Bounded Context Splitting

We split into three bounded contexts: Order, Product, and Scheduling. Each may correspond to a separate system and communicate via remote calls.

3.2.2 Modules and Packages

Modules include:

ddd-demo/</code><code>├── ddd-order-demo-api      # UI layer – REST</code><code>│   └── src/main/java/.../controller/OrderController.java</code><code>├── ddd-order-demo-application   # Application service layer</code><code>│   └── src/main/java/.../service/OrderService.java</code><code>├── ddd-order-demo-domain        # Domain layer</code><code>│   └── src/main/java/.../model/order/Order.java</code><code>│   └── src/main/java/.../service/OrderDomainService.java</code><code>├── ddd-order-demo-infra-remote  # Remote infrastructure</code><code>│   └── src/main/java/.../client/ProductApiClient.java</code><code>├── ddd-order-demo-infra-repository # Repository infrastructure</code><code>│   └── src/main/java/.../repository/OrderRepository.java

3.2.3 Example: User Order Flow

Key steps:

Validate user existence.

Convert request to domain model.

Check inventory via ProductDomainService.

Lock inventory via ScheduleRemoteService.

Create order via OrderDomainService.

Persist order via OrderRepository.

Publish order creation event.

Code snippets:

@RestController</code><code>@RequestMapping("/order")</code><code>public class OrderController {</code><code>    @Resource</code><code>    OrderService orderService;</code><code>    @PostMapping("/create")</code><code>    public OrderResponse create(OrderCreateCmd cmd) {</code><code>        return OrderConvertor.convertToResponse(orderService.createOrder(cmd));</code><code>    }</code><code>}
@Transactional(rollbackFor = Exception.class)</code><code>public Order createOrder(OrderCreateCmd cmd) {</code><code>    String orderNo = UUID.randomUUID().toString();</code><code>    User user = userRepository.findById(cmd.getUserId())</code><code>        .orElseThrow(() -> new DemoBusinessException("User not found"));</code><code>    List<OrderItem> items = makeOrderItems(cmd.getProductItems(), orderNo);</code><code>    orderDomainService.checkInventoryAndAssembleOrderItems(items);</code><code>    DeliveryAddress address = deliveryAddressRepository.findById(cmd.getDeliveryAddressId())</code><code>        .orElseThrow(() -> new DemoBusinessException("Address not found"));</code><code>    Order order = Order.create(orderNo, address, items, user.getUserId());</code><code>    orderDomainService.lockInventory(order);</code><code>    orderRepository.createOrder(order);</code><code>    orderMessageProducer.publish(order, OrderEventTypeEnum.INIT);</code><code>    return order;</code><code>}
public void checkInventoryAndAssembleOrderItems(List<OrderItem> orderItems) {</code><code>    if (CollectionUtils.isEmpty(orderItems)) {</code><code>        throw new DemoBusinessException("No items selected");</code><code>    }</code><code>    List<Long> productIds = orderItems.stream()</code><code>        .map(OrderItem::getProductId).collect(Collectors.toList());</code><code>    List<Product> products = productRemoteService.getProductInfos(productIds);</code><code>    if (CollectionUtils.isEmpty(products)) {</code><code>        throw new DemoBusinessException("Products not found");</code><code>    }</code><code>    Map<Long, Product> productMap = products.stream()</code><code>        .collect(Collectors.toMap(Product::getProductId, p -> p));</code><code>    for (OrderItem item : orderItems) {</code><code>        Product product = productMap.get(item.getProductId());</code><code>        if (product == null) {</code><code>            throw new DemoBusinessException("Product " + item.getProductName() + " not exist");</code><code>        }</code><code>        if (product.getInventoryCount() < item.getCount()) {</code><code>            throw new DemoBusinessException("Product " + product.getProductName() + " insufficient inventory");</code><code>        }</code><code>        item.setPrice(product.getPrice());</code><code>        item.setProductName(product.getProductName());</code><code>    }</code><code>}</code><pre><code>public void lockInventory(Order order) {</code><code>    LockInventoryResponse resp = scheduleRemoteService.lockInventory(order)</code><code>        .orElseThrow(() -> new DemoBusinessException("Lock inventory failed"));</code><code>    if (resp.getLockEndTime().before(new Date())) {</code><code>        throw new DemoBusinessException("Lock inventory failed");</code><code>    }</code><code>    order.setLockInventoryEndTime(resp.getLockEndTime());</code><code>}

3.3 Infrastructure – Remote Service Example

public class ProductRemoteService implements IProductRemoteService {</code><code>    @Resource</code><code>    ProductApiClient productApiClient;</code><code>    @Override</code><code>    public List<Product> getProductInfos(List<Long> productIds) {</code><code>        BaseRemoteResponse<List<Product>> resp = productApiClient.getProductInfos(productIds);</code><code>        if (resp == null || resp.failed()) {</code><code>            log.error("getProductInfos error, request:{}, response:{}", productIds, resp);</code><code>            return Collections.emptyList();</code><code>        }</code><code>        return resp.getData();</code><code>    }</code><code>}

3.4 Infrastructure – Repository Example

@Repository</code><code>public class OrderRepository implements IOrderRepository {</code><code>    @Resource</code><code>    OrderMapper orderMapper;</code><code>    @Resource</code><code>    OrderItemMapper orderItemMapper;</code><code>    @Resource</code><code>    private RedisTemplate<String, Object> redisTemplate;</code><code>    @Resource</code><code>    private ElasticsearchTemplate<OrderEntityES, Long> orderESTemplate;</code><code>    public Optional<Order> findById(long orderId) {</code><code>        return Optional.ofNullable(OrderConvertor.convertToDO(orderESTemplate.getById(orderId, OrderEntity.class)));</code><code>    }</code><code>    @Override</code><code>    public void createOrder(Order order) {</code><code>        OrderEntity entity = OrderConvertor.convertToEntity(order);</code><code>        orderMapper.insert(entity);</code><code>        order.setOrderId(entity.getId());</code><code>        for (OrderItem item : order.getOrderItemList()) {</code><code>            item.setOrderId(entity.getId());</code><code>            orderItemMapper.insert(OrderItemConvertor.INSTANCT.convertToEntity(item));</code><code>        }</code><code>    }</code><code>}

4. Huolala User CRM Refactoring Practice

4.1 Why Refactor

As the system grows, complexity rises, leading to maintenance and stability risks. The goals of refactoring are:

Scalability : Move to hexagonal architecture.

Stability : Reduce code complexity via business orchestration.

Delivery efficiency : Improve code understandability and shorten development cycles.

4.2 Refactoring Plan

Two common migration patterns were evaluated: the "Killer" pattern (new features in new modules, gradually replace old code) and the "Repair" pattern (extract domain layer, invert dependencies, and gradually replace implementations). The team chose the Repair pattern.

The plan consists of four stages:

Data layer dependency inversion – extract empty domain layer, define repository interfaces, and let DAO implement them.

Domain layer refactor – move business logic to domain services, keep application services lightweight.

Remote layer dependency inversion – define remote interfaces in domain, let remote implementations depend on them.

Final cleanup – standardize package names, extract shared components.

4.3 Summary

The refactoring took about three months, using the Repair pattern with parallel business iteration. No stability issues occurred. After migration, the system gained clearer model boundaries, better evolvability, and over 50% cost reduction for middleware changes.

Key takeaways:

DDD has no single standard; design must fit the actual business.

Focus on business orchestration and architecture rather than strict DDD concepts.

Remember DDD’s purpose: improve maintainability and extensibility of complex business systems.

Appendix A: Key Terms

Bounded Context : An explicit boundary where a domain model is expressed in software.

Domain : The problem space; can be split into sub‑domains.

Sub‑Domain : A more granular focus within a domain.

Domain Model : Models defined and used in the domain layer.

Data Model : Models defined and used in the data layer (tables, entities).

Repository : Any component that stores data (DB, cache, remote service).

Aggregate & Aggregate Root : See section 3.2.1.

Appendix B: Common Questions

Can I skip rich (active) models? Yes. If using frameworks like MyBatis, a lightweight model may be more practical. You can treat data models as domain models when appropriate.

Final Note : DDD should serve business needs, not become a dogma. Apply its principles flexibly to achieve maintainable, extensible systems.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Backend DevelopmentDomain-Driven DesignDDDHexagonal Architecture
Huolala Tech
Written by

Huolala Tech

Technology reshapes logistics

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.