×
Community Blog An Alibaba Cloud Technical Expert's Insight Into Domain-driven Design: Domain Primitive

An Alibaba Cloud Technical Expert's Insight Into Domain-driven Design: Domain Primitive

This article introduces the elementary yet valuable concept of domain primitive using several business case scenarios to establish and strengthen the learning path for domain-driven design (DDD).

By Luan Guangmiao (Yinhao)

1

Introduction

Every architect strives to minimize system complexity in software development. A series of books, such as Design Patterns: Elements of Reusable Object-Oriented Software by Erich Gamma, Richard Helm, Ralph Johnson, and John Vlissides in 1994, Refactoring by Martin Fowler in 1999, Patterns of Enterprise Application Architecture in 2002, and Enterprise Integration Patterns in 2003 Plethora, advocates the practice of minimizing system complexity through design patterns or paradigms. These books propose the concept of solving technical problems by technical means. However, they leave business problems unsolved. A complete set of architecture concepts for business development are proposed in some other books, such as Domain-Driven Design by Eric Evans in 2003, Implementing DDD by Vaughn Vernon, and Clean Architecture by Uncle Bob.

Preface

Domain-driven design (DDD) is an architectural concept rather than an architecture, so it lacks sufficient constraints for coding. This makes it difficult to apply DDD to actual software development and causes a great deal of misunderstanding about DDD. For example, Martin Fowler described an anti-pattern called anemic domain model in his blog. This model has been widely used in actual applications and promoted by the use of popular object-relational mapping (ORM) frameworks such as Hibernate and Entity Framework. The four-tier architecture, which consists of a user interface (UI) tier, business tier, data access tier, and database tier, uses database technologies and Model-View-Controller (MVC). This architecture is often confused with DDD, reducing DDD to a modeling concept, ignoring its architecture.

I first learned about DDD in 2012, when many business applications (except those pertaining to large Internet companies) were developed based on the monolith architecture. Service-oriented architecture (SOA) was only used to develop monolith applications with load balancing (LB) and used MVC to provide RESTful APIs for external calls. A remote procedure call (RPC) depended on the Simple Object Access Protocol (SOAP), web services, and protocols with external dependencies. My interest in DDD was aroused by the concept of an anti-corruption layer, which isolates the core business logic from external dependencies when these change frequently. In 2014, SOA became popular and the concept of microservices emerged. Many technical forums talked about how to properly split a monolith application into multiple microservices. In DDD, bounded context provides a proper framework for microservice splitting. With the advent of anything as a service (XaaS), DDD allows us to think about what can be split into services and what types of logic need to be aggregated to minimize maintenance costs rather than simply maximize development efficiency.

Therefore, I decided to write a series of articles on DDD, hoping to promote the concepts of DDD based on previous experience and lower the barriers to DDD application through a proper code structure, a framework, and related constraints. My goal is to improve the quality, testability, security, and robustness of code.

The subsequent articles will cover the following content:

  • Best architecture practices: the core ideas and implementation solutions of the hexagon application architecture and clean architecture
  • Continuous discovery and delivery: event storming -> context map -> design heuristics -> modeling
  • Reducing the speed of architecture corruption: integrating the anti-corruption layer with the modular solution of a third-party library
  • Specifications and boundaries of standard components, such as entity, aggregate, repository, domain service, application service, event, and DTO assembler
  • Redefining application service boundaries based on use cases
  • DDD-based microservice transformation and fine-grained control
  • Transformation and challenges of Command Query Responsibility Segregation (CQRS)
  • Challenges of an event-driven architecture
  • And more

In this article, I will introduce an elementary but valuable concept: domain primitive.

Domain Primitive

Just as we need to understand the elementary data types before learning any language, we must understand the basic concept of domain primitive before learning DDD.

Primitive is defined as follows:

  • Not developed from anything else
  • In the first or early stage of formation or growth

Similar to integers and strings, which are the primitive elements of all programming languages, domain primitives are the basic and omnipresent elements of all models, methods, and architectures in DDD. The following gives a comprehensive introduction to domain primitive based on distinct cases.

1) Case Analysis

Let's look at a simple case with the following business logic:

A new application is promoted nationwide by regional sales representatives. We need to build a user registration system and issue bonuses to sales representatives based on the area codes of the landline numbers of registered users.

Let's work out proper ways to implement the preceding business logic. The following code implements simple user registration:

public class User {
    Long userId;
    String name;
    String phone;
    String address;
    Long repId;
}

public class RegistrationServiceImpl implements RegistrationService {

    private SalesRepRepository salesRepRepo;
    private UserRepository userRepo;

    public User register(String name, String phone, String address) 
      throws ValidationException {
        // 校验逻辑
        if (name == null || name.length() == 0) {
            throw new ValidationException("name");
        }
        if (phone == null || !isValidPhoneNumber(phone)) {
            throw new ValidationException("phone");
        }
        // 此处省略address的校验逻辑

        // 取电话号里的区号,然后通过区号找到区域内的SalesRep
        String areaCode = null;
        String[] areas = new String[]{"0571", "021", "010"};
        for (int i = 0; i < phone.length(); i++) {
            String prefix = phone.substring(0, i);
            if (Arrays.asList(areas).contains(prefix)) {
                areaCode = prefix;
                break;
            }
        }
        SalesRep rep = salesRepRepo.findRep(areaCode);

        // 最后创建用户,落盘,然后返回
        User user = new User();
        user.name = name;
        user.phone = phone;
        user.address = address;
        if (rep != null) {
            user.repId = rep.repId;
        }

        return userRepo.save(user);
    }

    private boolean isValidPhoneNumber(String phone) {
        String pattern = "^0[1-9]{2,3}-?\\d{8}$";
        return phone.matches(pattern);
    }
}

The preceding code is a typical example and seems fine. Let's analyze it along four dimensions: API definition (readability), data verification and error handling, business logic code definition, and testability.

API Definition

In Java code, all the parameter names of a method are lost during compile time, leaving only a list of parameter types. The preceding API definition is as follows at runtime.

User register(String, String, String);

The following code contains a bug that is difficult to identify and not reported by a compiler.

service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");

This bug may be identified when the code is executed rather than during compile time. This bug is difficult to identify during code review and may be exposed only after the code is published. Is it possible to avoid this bug during compilation?

The following code is common in query services:

User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

All the input parameters are of the string type and must be differentiated by prefixing the method name with ByXXX. findByNameAndPhone is also prone to disordered input parameters, in which case only null values, not errors, are returned. This bug is more difficult to identify. We have to figure out a way to define the order of input parameters in a method to avoid the resulting bug.

Data Verification and Error Handling

The code for data verification is as follows.

if (phone == null || !isValidPhoneNumber(phone)) {
    throw new ValidationException("phone");
}

The preceding verification code is often used and typically appears before a method to ensure fail-fast. However, if we have multiple similar APIs and similar input parameters, this logic is repeated in each method. If we also include mobile numbers in addition to landline numbers, the verification code changes as follows.

if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
    throw new ValidationException("phone");
}

A bug may occur if the verification code is missing from any of the methods that contain the "phone" input parameter. This bug often occurs when the "Don't Repeat Yourself" principle is violated.

If we want to get an input parameter error message in return, modify the verification code as follows, making it more complex.

if (phone == null) {
    throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
    throw new ValidationException("phone格式错误");
}

Data verification requires a large number of similar code blocks that may greatly increase the maintenance cost. ValidationException may be implicitly or explicitly thrown in this business method, requiring the external caller to execute the try-catch block. Is it proper to mix exceptions related to business logic and data verification?

The traditional Java architecture allows adding the Bean Validation annotation or ValidationUtils class to partially solve this problem.

// Use Bean Validation
User registerWithBeanValidation(
  @NotNull @NotBlank String name,
  @NotNull @Pattern(regexp = "^0?[1-9]{2,3}-?\\d{8}$") String phone,
  @NotNull String address
);

// Use ValidationUtils:
public User registerWithUtils(String name, String phone, String address) {
    ValidationUtils.validateName(name); // throws ValidationException
    ValidationUtils.validatePhone(phone);
    ValidationUtils.validateAddress(address);
    ...
}

However, the Bean Validation annotation and ValidationUtils class are also problematic.

Bean Validation

  • Bean Validation is only applicable to simple verification logic. Implementing complex verification logic requires writing code to implement a custom verifier.
  • Add the Bean Validation annotation in every required place in the new verification logic. If it is missing from any required place, the DRY principle is violated.

ValidationUtils Class

  • The Single Responsibility principle is violated when a large amount of verification logic is concentrated in one class. As a result, the code looks confusing and is unmaintainable.
  • Business exceptions and verification exceptions are still mixed.

Is there a way to solve all verification problems and reduce the costs of subsequent maintenance and exception handling?

Business Code Definition

Let's have a look at the following code:

String areaCode = null;
String[] areas = new String[]{"0571", "021", "010"};
for (int i = 0; i < phone.length(); i++) {
    String prefix = phone.substring(0, i);
    if (Arrays.asList(areas).contains(prefix)) {
        areaCode = prefix;
        break;
    }
}
SalesRep rep = salesRepRepo.findRep(areaCode);

The preceding code extracts a portion of data from some input parameters, calls an external dependency to retrieve more data, and then extracts a portion of data from new data for other purposes. Such code is called glue code, and serves to adapt the input parameters of the external-dependency service to be compatible with the original input parameters. For example, trim down the preceding code by including a findRepByPhone method in SalesRepRepository.

A common solution is to extract the code and convert it into one or more separate methods.

private static String findAreaCode(String phone) {
    for (int i = 0; i < phone.length(); i++) {
        String prefix = phone.substring(0, i);
        if (isAreaCode(prefix)) {
            return prefix;
        }
    }
    return null;
}

private static boolean isAreaCode(String prefix) {
    String[] areas = new String[]{"0571", "021"};
    return Arrays.asList(areas).contains(prefix);
}

The original code is changed as follows.

String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

Extract the static tool class PhoneUtils when reusing the preceding methods. Now, determine whether the static tool class achieves the optimal effect. Is it still possible to find the core business logic in a project that is full of static tool classes, with business code scattered across multiple files?

Testability

To ensure the quality of the code, execute test cases for every possible condition of every input parameter in every method, assuming that the internal business logic is not tested for the moment. This method requires the following test cases:

2

If a method has N parameters and each parameter has M segments of verification logic, then at least N x M test cases are required.

If we add the input parameter "fax" to the method and this parameter has the same verification logic as the "phone" input parameter, we need to add M test cases to the "fax" input parameter to cover all the possible conditions of this parameter.

Assume that the "phone" input parameter is used by P methods and must be tested by all these methods.

The total number of test cases required for data verification is:

P N M

The resulting testing costs are unacceptable for common projects and may produce missing test cases in some code. Bugs are most likely to occur in untested code.

This case requires reduction in the testing costs to test more code and improve its quality.

2) Solution

Let's review the use case at the beginning of section 1 and identify some important concepts.

Assume a new application is promoted nationwide by regional sales representatives. We need to build a user registration system and issue bonuses to sales representatives based on the area codes of the phone numbers of registered users.

After analyzing the use case, we identify the ID attributes of regional sales representatives and users as a type of entity and identify the registration system as a type of application service. However, the phone number concept is completely hidden in the code. We need to determine whether the logic of retrieving the area codes of phone numbers belongs to the user part or the registration service part. If the logic belongs to neither, it is an independent concept. This introduces the first principle:

Make Implicit Concepts Explicit

The "phone" input parameter is a user-specific parameter and an implicit concept. The real business logic is the retrieval of the area codes of phone numbers. Therefore, we need to write a value object to make the "phone" parameter explicit.

public class PhoneNumber {
  
    private final String number;
    public String getNumber() {
        return number;
    }

    public PhoneNumber(String number) {
        if (number == null) {
            throw new ValidationException("number不能为空");
        } else if (isValid(number)) {
            throw new ValidationException("number格式错误");
        }
        this.number = number;
    }

    public String getAreaCode() {
        for (int i = 0; i < number.length(); i++) {
            String prefix = number.substring(0, i);
            if (isAreaCode(prefix)) {
                return prefix;
            }
        }
        return null;
    }

    private static boolean isAreaCode(String prefix) {
        String[] areas = new String[]{"0571", "021", "010"};
        return Arrays.asList(areas).contains(prefix);
    }

    public static boolean isValid(String number) {
        String pattern = "^0?[1-9]{2,3}-?\\d{8}$";
        return number.matches(pattern);
    }

}

The important elements are described as follows.

  • "private final String number" is used to ensure that PhoneNumber is an immutable value object. Value objects are typically immutable.
  • The verification logic is included in the constructor. Verification is successful as long as the PhoneNumber class is created.
  • The findAreaCode method is changed to getAreaCode in the PhoneNumber class, indicating that areaCode is a computing attribute of PhoneNumber.

After making PhoneNumber explicit, we actually create a data type and a class.

  • The data type is used to explicitly identify PhoneNumber in subsequent code.
  • The class is used to include all logic related to PhoneNumber in a file.

The data type and class are called domain primitives. The code with domain primitives is as follows.

public class User {
    UserId userId;
    Name name;
    PhoneNumber phone;
    Address address;
    RepId repId;
}

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) {
    // 找到区域内的SalesRep
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());

    // 最后创建用户,落盘,然后返回,这部分代码实际上也能用Builder解决
    User user = new User();
    user.name = name;
    user.phone = phone;
    user.address = address;
    if (rep != null) {
        user.repId = rep.repId;
    }

    return userRepo.saveUser(user);
}

With domain primitives, the code is trimmed down to the core business logic, and the data verification logic and non-business logic are removed. Let's reevaluate the code along the four dimensions mentioned earlier.

API Definition

The method signature is clear after refactoring:

public User register(Name, PhoneNumber, Address)

The rewritten code is free of the previously-mentioned bugs.

service.register(new Name("殷浩"), new Address("浙江省杭州市余杭区文三西路969号"), new PhoneNumber("0571-12345678"));

The API is concise and easy to extend.

Data Verification and Error Handling

public User register(
  @NotNull Name name,
  @NotNull PhoneNumber phone,
  @NotNull Address address
) // no throws

The method after refactoring has no data verification logic and does not throw ValidationException. Domain primitives ensure that only correct or null input parameters are used. Null input parameters are identified through Bean Validation or Lombok annotations. This means it is better to transfer data verification to the caller who is supposed to provide valid data.

The domain primitives make the code compatible with the DRY and Single Responsibility principles. Just modify the PhoneNumber verification logic in only one file, and the modified logic takes effect wherever PhoneNumber is used.

Business Code Definition

SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
User user = xxx;
return userRepo.save(user);

The domain primitives not only remove data verification from business methods but also change the glue code findAreaCode to the computing attribute getAreaCode of the PhoneNumber class. This makes the code clearer. Without the non-reusable glue code, the original code becomes reusable and testable after the domain primitives are added. After the data verification code and glue code are removed, The code only contains the core business logic. Entity-related refactoring will be discussed in a subsequent article, so we will not go into details here.

Testability

3

After PhoneNumber is retrieved, the test cases are as follows:

  • M test cases are still required by PhoneNumber, but the code in each test case is greatly reduced because only one object is tested. This lowers the maintenance cost.
  • For every parameter in every method, the code only needs to check whether the parameter value is null. Other conditions are impossible because all non-null parameters are valid.

This reduces the number of test cases for every method from N x M to N + M. The number of test cases for multiple methods changes to: N + M + P.

This number is much less than N M P, indicating a significant reduction in the testing costs.

3) Advanced Usage

We have now learned the first principle for using domain primitives: Make Implicit Concepts Explicit. Now, let's look at the other two principles for using domain primitives in new cases.

Case 1) Account Transfer

In this case, we want to allow User A to pay RMB X to User B. This function is implemented as follows.

public void pay(BigDecimal money, Long recipientId) {
    BankService.transfer(money, "CNY", recipientId);
}

This method is applicable to domestic transfers with no currency conversion. However, a bug may occur when the currency is changed (like what happened in the Eurozone) or cross-border transfer is required because the currency indicated by "money" is not necessarily CNY.

The action of paying RMB X involves not only the amount X but also the CNY currency, which is an implicit concept. Among the input parameters, only the BigDecimal object is used because CNY is considered to be the default currency and is an implicit condition. However, while coding, there is need to make all implicit conditions explicit. Together, these conditions constitute the context. Therefore, the second principle for using domain primitives is:

Make Implicit Context Explicit

Therefore, when compiling code for this payment function, pass an input parameter that combines the payment amount and currency. This input parameter is Money.

@Value
public class Money {
    private BigDecimal amount;
    private Currency currency;
    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;
        this.currency = currency;
    }
}

The preceding code is modified as follows.

public void pay(Money money, Long recipientId) {
    BankService.transfer(money, recipientId);
}

Many possible bugs are avoided when the implicit concept "currency" is made explicit and combined with the payment amount in the Money parameter.

Case 2) Cross-border Transfer

This case involves a cross-border transfer from the currency CNY to USD, with constant changes of the exchange rate.

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    if (money.getCurrency().equals(targetCurrency)) {
        BankService.transfer(money, recipientId);
    } else {
        BigDecimal rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
        BigDecimal targetAmount = money.getAmount().multiply(new BigDecimal(rate));
        Money targetMoney = new Money(targetAmount, targetCurrency);
        BankService.transfer(targetMoney, recipientId);
    }
}

A service must be called to retrieve the exchange rate for calculation because targetCurrency is not necessarily the same as the currency indicated by the "money" parameter. The calculated amount will be transferred.

In this case, it's critical to solve two problems:

(1) The amount calculation is included in the payment service;
(2) Five objects are involved, including two Currency objects, two Money objects, and one BigDecimal object. Use domain primitives to encapsulate the business logic that involves multiple objects. This introduces the third principle for using domain primitives:

Encapsulate Multi-object Behavior

The exchange rate conversion function must be encapsulated in the domain primitive ExchangeRate.

@Value
public class ExchangeRate {
    private BigDecimal rate;
    private Currency from;
    private Currency to;

    public ExchangeRate(BigDecimal rate, Currency from, Currency to) {
        this.rate = rate;
        this.from = from;
        this.to = to;
    }

    public Money exchange(Money fromMoney) {
        notNull(fromMoney);
        isTrue(this.from.equals(fromMoney.getCurrency()));
        BigDecimal targetAmount = fromMoney.getAmount().multiply(rate);
        return new Money(targetAmount, to);
    }
}

The original code is simplified when the amount calculation logic and verification logic are encapsulated in ExchangeRate.

public void pay(Money money, Currency targetCurrency, Long recipientId) {
    ExchangeRate rate = ExchangeService.getRate(money.getCurrency(), targetCurrency);
    Money targetMoney = rate.exchange(money);
    BankService.transfer(targetMoney, recipientId);
}

4) Discussion and Summary

Definition of Domain Primitive

A domain primitive is an accurately defined and behavior-oriented value object with self-verification in a specific domain.

  • Domain primitives are immutable.
  • A domain primitive is a complete concept with a precise definition.
  • Domain primitives use the native language of the business domain.
  • A domain primitive is the smallest component of the business domain and can be used to build complex combinations.

Note: The concepts and naming rules of domain primitive are proposed in the book, Secure by Design by Dan Bergh Johnsson and Daniel Deogun.

Three Principles for Using Domain Primitives

  • Make Implicit Concepts Explicit
  • Make Implicit Context Explicit
  • Encapsulate Multi-object Behavior

Differences Between Domain Primitives and Value Objects in DDD

The value object concept exists in DDD.

  • In the book Domain Driven Design by Eric Evans, value objects are mostly of the non-entity type.
  • In the book Implementing DDD by Vaughn Vernon, the author focuses on the immutability attribute, Equals method, and Factory method of value objects.

Domain primitives are the advanced version of value objects and each domain primitive is defined based on the concept of value objects. In addition to the immutability inherited from value objects, domain primitives also have the validity and behavior attributes. Both, value objects and domain primitives must be side-effect free.

Differences Between Domain Primitives and Data Transfer Objects

Data transfer objects (DTOs) are a common type of data structure in development. The input and output parameters in a method are DTOs. The differences between domain primitives and DTOs are as follows.

4

Scenarios Suitable for Domain Primitives

Domain Primitives are applicable to the following scenarios:

  • Strings with limited formats, such as Name, PhoneNumber, OrderNumber, ZipCode, and Address
  • Limited integers, such as OrderId (>0), Percentage (0-100%), and Quantity (>=0)
  • Enumerated int such as Status (generally, Enum is not used due to deserialization)
  • Double or BigDecimal domain primitives with business meanings, such as Temperature, Money, Amount, ExchangeRate, and Rating
  • Complex data structure such as Map. Encapsulate all operations of Map and expose only necessary behaviors.

5) Practice - Refactoring Old Applications

It is easy to use domain primitives in new applications. To use domain primitives in old applications, take the following steps. Let's return to the first case we used in this article.

Step 1) Create Domain Primitives and Collect all Domain Primitive Behaviors

The logic for retrieving the area codes of phone numbers must be encapsulated in the PhoneNumber class. Similarly, in actual projects, extract the code that is scattered across services or tool classes and encapsulate the extracted code in domain primitives as a kind of behavior or attribute. The extracted methods, such as the original static methods, must be stateless. If the original method encounters a state change, separate the changed part from the unchanged part and encapsulate the stateless part in a domain primitive. Domain primitives are stateless and therefore are not applicable to code that requires state change.

For more information about the code, see the code related to PhoneNumber.

Step 2) Replace the Data Verification Logic and Stateless Logic

To ensure compatibility with existing methods, rewrite the code to replace the original data verification logic and domain primitive-related business logic rather than modify the API signature. Consider the following example.

public User register(String name, String phone, String address)
        throws ValidationException {
    if (name == null || name.length() == 0) {
        throw new ValidationException("name");
    }
    if (phone == null || !isValidPhoneNumber(phone)) {
        throw new ValidationException("phone");
    }
    
    String areaCode = null;
    String[] areas = new String[]{"0571", "021", "010"};
    for (int i = 0; i < phone.length(); i++) {
        String prefix = phone.substring(0, i);
        if (Arrays.asList(areas).contains(prefix)) {
            areaCode = prefix;
            break;
        }
    }
    SalesRep rep = salesRepRepo.findRep(areaCode);
    // 其他代码...
}

The preceding code is rewritten with domain primitives as follows.

public User register(String name, String phone, String address)
        throws ValidationException {
    
    Name _name = new Name(name);
    PhoneNumber _phone = new PhoneNumber(phone);
    Address _address = new Address(address);
    
    SalesRep rep = salesRepRepo.findRep(_phone.getAreaCode());
    // 其他代码...
}

The data verification code is replaced with a new PhoneNumber(phone). The stateless business logic is replaced with _phone.getAreaCode().

Step 3) Create an API

Create an API and encapsulate the API parameters in domain primitives.

public User register(Name name, PhoneNumber phone, Address address) {
    SalesRep rep = salesRepRepo.findRep(phone.getAreaCode());
}

Step 4) Modify External Calls

The external caller needs to modify the trace, for example:

service.register("殷浩", "0571-12345678", "浙江省杭州市余杭区文三西路969号");

The code is modified as follows.

service.register(new Name("殷浩"), new PhoneNumber("0571-12345678"), new Address("浙江省杭州市余杭区文三西路969号"));

The preceding four steps make the code simpler, more elegant, more robust, and more secure.

That's it! Now, go ahead and try it yourself.

Are you eager to know the latest tech trends in Alibaba Cloud? Hear it from our top experts in our newly launched series, Tech Show!

1 1 0
Share on

淘系技术

12 posts | 1 followers

You may also like

Comments

5137592763077743 June 21, 2020 at 6:29 pm

I just discovered Alibaba cloud posts about DDD and domain modeling. Very informative, intuitive, and most importantly easy to understand. Also, examples are interesting (not just from this post). Thank you.