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.
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.java3.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.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
