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

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.

  • 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

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.

  • In the first or early stage of formation or growth

1) Case Analysis

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

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);
}
}

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);
service.register("殷浩", "浙江省杭州市余杭区文三西路969号", "0571-12345678");
User findByName(String name);
User findByPhone(String phone);
User findByNameAndPhone(String name, String phone);

Data Verification and Error Handling

The code for data verification is as follows.

if (phone == null || !isValidPhoneNumber(phone)) {
throw new ValidationException("phone");
}
if (phone == null || !isValidPhoneNumber(phone) || !isValidCellNumber(phone)) {
throw new ValidationException("phone");
}
if (phone == null) {
throw new ValidationException("phone不能为空");
} else if (!isValidPhoneNumber(phone)) {
throw new ValidationException("phone格式错误");
}
// 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);
...
}
  • 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.
  • Business exceptions and verification exceptions are still mixed.

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);
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);
}
String areaCode = findAreaCode(phone);
SalesRep rep = salesRepRepo.findRep(areaCode);

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) Solution

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

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 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.
  • The class is used to include all logic related to PhoneNumber in a file.
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);
}

API Definition

The method signature is clear after refactoring:

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

Data Verification and Error Handling

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

Business Code Definition

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

Testability

  • 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.

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);
}
@Value
public class Money {
private BigDecimal amount;
private Currency currency;
public Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
}
public void pay(Money money, Long recipientId) {
BankService.transfer(money, recipientId);
}

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);
}
}
@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);
}
}
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.

  • 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.

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 Implementing DDD by Vaughn Vernon, the author focuses on the immutability attribute, Equals method, and Factory method of value objects.

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.

Scenarios Suitable for Domain Primitives

Domain Primitives are applicable to the following scenarios:

  • 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.

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);
// 其他代码...
}
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());
// 其他代码...
}

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

Original Source:

--

--

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store