Yin Hao explains DDD in detail: domain level design specification

Posted by BillyB on Wed, 09 Feb 2022 10:29:52 +0100

Introduction: in a DDD architecture design, the design rationality of domain layer will directly affect the code structure of the whole architecture and the design of application layer and infrastructure layer. However, domain layer design is a challenging task, especially in an application with relatively complex business logic. It is worth thinking carefully whether each business rule should be placed on Entity, ValueObject or DomainService. We should not only avoid poor scalability in the future, but also ensure that excessive design will not lead to complexity. Today, I use a relatively easy to understand field to do a case demonstration, but in the actual business application, whether it is transaction, marketing or interaction, it can be realized with similar logic.

Author Yin Hao
Source: Ali technical official account

In a DDD architecture design, the design rationality of domain layer will directly affect the code structure of the whole architecture and the design of application layer and infrastructure layer. However, domain layer design is a challenging task, especially in an application with relatively complex business logic. It is worth thinking carefully whether each business rule should be placed on Entity, ValueObject or DomainService. We should not only avoid poor scalability in the future, but also ensure that excessive design will not lead to complexity. Today, I use a relatively easy to understand field to do a case demonstration, but in the actual business application, whether it is transaction, marketing or interaction, it can be realized with similar logic.

On the world structure of dragon and Magic

1 background and rules

I've read a lot of serious business code on weekdays. Today, I'm looking for a relaxed topic. How to use code to realize the (minimalist) Rules of a dragon and Magic game world?

The basic configuration is as follows:

  • Player s can be fighters, mages and dragoons
  • Monsters can be orcs, Elf and dragons. Monsters have blood
  • Weapon can be Sword or Staff. Weapon has attack power

Players can be equipped with a weapon. The weapon attack can be physical type (0), fire (1), ice (2), etc. the weapon type determines the damage type. The attack rules are as follows:

  • Orc's damage to physical attack reduced by half
  • The damage of elves to magic attack is halved
  • Dragons are immune to physical and magic attacks. Unless the player is a Dragon Rider, the damage is doubled

2 OOP implementation

For students familiar with object oriented programming, a relatively simple implementation is through the inheritance relationship of classes (some non core codes are omitted here):

public abstract class Player {
      Weapon weapon
}
public class Fighter extends Player {}
public class Mage extends Player {}
public class Dragoon extends Player {}

public abstract class Monster {
    Long health;
}
public Orc extends Monster {}
public Elf extends Monster {}
public Dragoon extends Monster {}

public abstract class Weapon {
    int damage;
    int damageType; // 0 - physical, 1 - fire, 2 - ice etc.
}
public Sword extends Weapon {}
public Staff extends Weapon {}

The implementation rule code is as follows:

public class Player {
    public void attack(Monster monster) {
        monster.receiveDamageBy(weapon, this);
    }
}

public class Monster {
    public void receiveDamageBy(Weapon weapon, Player player) {
        this.health -= weapon.getDamage(); // Basic rules
    }
}

public class Orc extends Monster {
    @Override
    public void receiveDamageBy(Weapon weapon, Player player) {
        if (weapon.getDamageType() == 0) {
            this.setHealth(this.getHealth() - weapon.getDamage() / 2); // Orc's physical defense rules
        } else {
            super.receiveDamageBy(weapon, player);
        }
    }
}

public class Dragon extends Monster {
    @Override
    public void receiveDamageBy(Weapon weapon, Player player) {
        if (player instanceof Dragoon) {
            this.setHealth(this.getHealth() - weapon.getDamage() * 2); // Dragon riding injury rules
        }
        // else no damage
    }
}

Then run several single tests:

public class BattleTest {

    @Test
    @DisplayName("Dragon is immune to attacks")
    public void testDragonImmunity() {
        // Given
        Fighter fighter = new Fighter("Hero");
        Sword sword = new Sword("Excalibur", 10);
        fighter.setWeapon(sword);
        Dragon dragon = new Dragon("Dragon", 100L);

        // When
        fighter.attack(dragon);

        // Then
        assertThat(dragon.getHealth()).isEqualTo(100);
    }

    @Test
    @DisplayName("Dragoon attack dragon doubles damage")
    public void testDragoonSpecial() {
        // Given
        Dragoon dragoon = new Dragoon("Dragoon");
        Sword sword = new Sword("Excalibur", 10);
        dragoon.setWeapon(sword);
        Dragon dragon = new Dragon("Dragon", 100L);

        // When
        dragoon.attack(dragon);

        // Then
        assertThat(dragon.getHealth()).isEqualTo(100 - 10 * 2);
    }

    @Test
    @DisplayName("Orc should receive half damage from physical weapons")
    public void testFighterOrc() {
        // Given
        Fighter fighter = new Fighter("Hero");
        Sword sword = new Sword("Excalibur", 10);
        fighter.setWeapon(sword);
        Orc orc = new Orc("Orc", 100L);

        // When
        fighter.attack(orc);

        // Then
        assertThat(orc.getHealth()).isEqualTo(100 - 10 / 2);
    }

    @Test
    @DisplayName("Orc receive full damage from magic attacks")
    public void testMageOrc() {
        // Given
        Mage mage = new Mage("Mage");
        Staff staff = new Staff("Fire Staff", 10);
        mage.setWeapon(staff);
        Orc orc = new Orc("Orc", 100L);

        // When
        mage.attack(orc);

        // Then
        assertThat(orc.getHealth()).isEqualTo(100 - 10);
    }
}

The above code and single test are relatively simple, so there is no unnecessary explanation.

Design defect analysis of OOP code

Strong typing in programming languages cannot host business rules

The above OOP code can work until we add a restriction:

  • Soldiers can only be equipped with swords
  • Mages can only equip staff

This rule cannot be implemented by strong typing in the Java language. Although Java has Variable Hiding (or C#'s new class variable), it actually only adds a new variable to the subclass, so it will lead to the following problems:

@Data
public class Fighter extends Player {
    private Sword weapon;
}

@Test
public void testEquip() {
    Fighter fighter = new Fighter("Hero");

    Sword sword = new Sword("Sword", 10);
    fighter.setWeapon(sword);

    Staff staff = new Staff("Staff", 10);
    fighter.setWeapon(staff);

    assertThat(fighter.getWeapon()).isInstanceOf(Staff.class); // Wrong
}

Finally, although the code feels like setWeapon(Staff), it actually only modifies the variables of the parent class and does not modify the variables of the child class, so it does not take effect or throw exceptions, but the result is wrong.

Of course, the setter can be restricted to protected in the parent class, but this limits the API of the parent class and greatly reduces the flexibility. At the same time, it also violates the Liskov substitution principle, that is, a parent class must be cast into a subclass before it can be used:

@Data
public abstract class Player {
    @Setter(AccessLevel.PROTECTED)
    private Weapon weapon;
}

@Test
public void testCastEquip() {
    Fighter fighter = new Fighter("Hero");

    Sword sword = new Sword("Sword", 10);
    fighter.setWeapon(sword);

    Player player = fighter;
    Staff staff = new Staff("Staff", 10);
    player.setWeapon(staff); // However, compilation should be open and available at the API level
}

Finally, if a rule is added:

  • Both warriors and mages can be equipped with dagger s

BOOM, the previously written strongly typed code is obsolete and needs to be refactored.

Object inheritance causes the code to strongly rely on the logic of the parent class, which violates the open closed principle (OCP)

The open close principle (OCP) stipulates that "objects should be open to extensions and closed to modifications ", although inheritance can extend new behavior through subclasses, because subclasses may directly depend on the implementation of the parent class, a change may affect all objects. In this example, if you add any type of player, monster or weapon, or add a rule, you may need to modify all methods from the parent class to the subclass.

For example, if you want to add a weapon type: sniper gun, which can ignore all defenses, the code that needs to be modified includes:

  • Weapon
  • Player and all subclasses (judgment of whether a weapon can be equipped)
  • Monster and all subclasses (damage calculation logic)
    public void receiveDamageBy(Weapon weapon, Player player) {
        this.health -= weapon.getDamage(); // Old basic rules
        if (Weapon instanceof Gun) { // New logic
            this.setHealth(0);
        }
    }
}

public class Dragon extends Monster {
    public void receiveDamageBy(Weapon weapon, Player player) {
        if (Weapon instanceof Gun) { // New logic
                      super.receiveDamageBy(weapon, player);
        }
        // Old logical ellipsis
    }
}

In a complex software, why do you suggest "try not to violate OCP"? The core reason is that a change in the existing logic may affect some original code, resulting in some unforeseen effects. This risk can only be guaranteed through complete unit test coverage, but it is difficult to ensure the coverage of single test in actual development. The principle of OCP can avoid this risk as much as possible. When new behavior can only be realized through new fields / methods, the behavior of old code will not change.

Although inheritance can Open for extension, it is difficult to close for modification. Therefore, the main way to solve OCP today is through composition over inheritance, that is, to achieve scalability through composition, rather than inheritance.

Player.attack(monster) or monster receiveDamage(Weapon, Player)?

In this example, there is an objection to where the logic of business rules should be written: when we look at the interaction between an object and another object, is it Player attacking Monster or Monster being attacked by Player? The current code mainly writes logic in Monster's class. The main consideration is that Monster will be injured and reduce Health. But if Player holds a double-edged sword, will he hurt himself at the same time? Did you find that there are problems in writing in Monster class? What are the principles of where the code is written?

Multiple objects behave similarly, resulting in code duplication

When we have different objects but have the same or similar behavior, OOP will inevitably lead to code duplication. In this example, if we add a "movable" behavior, we need to add similar logic in both Player and Monster classes:

public abstract class Player {
    int x;
    int y;
    void move(int targetX, int targetY) {
        // logic
    }
}

public abstract class Monster {
    int x;
    int y;
    void move(int targetX, int targetY) {
        // logic
    }
}

One possible solution is to have a generic parent class:

public abstract class Movable {
    int x;
    int y;
    void move(int targetX, int targetY) {
        // logic
    }
}

public abstract class Player extends Movable;
public abstract class Monster extends Movable;

But what if you add another Jump ability Jumpable? What about a Runnable? If Player can Move and Jump, Monster can Move and Run, how to deal with the inheritance relationship? You should know that Java (and most languages) does not support multi parent inheritance, so it can only be implemented through repeated code.

Problem summary

In this case, although the logic of OOP is very simple from the intuitive point of view, if your business is complex and there will be a large number of business rule changes in the future, the simple OOP code will become a complex paste in the later stage. The logic is scattered everywhere and lacks a global perspective. The superposition of various rules will trigger bug s. Do you feel deja vu? Yes, similar pitfalls are often encountered in links such as concessions and transactions in the e-commerce system. The core essence of such problems lies in:

  • Is the ownership of business rules the "behavior" of objects or independent "rule objects"?
  • How to handle the relationship between business rules?
  • How should common "behaviors" be reused and maintained?

Before talking about the solution of DDD, let's take a look at the recent popular architecture design in the game and how the entity component system (ECS) is implemented.

II. Introduction to entity component system (ECS) architecture

1. Introduction to ECS

ECS architecture mode is actually a very old game architecture design. It should be traced back to the component design of dungeon siege, but it has become popular recently because of the addition of Unity (for example, ECS is used in watchman). To quickly understand the value of ECS architecture, we need to understand the core problem of game code:

  • Performance: the game must achieve a high rendering rate (60FPS), that is, the whole game world needs to be completely updated once in 1/60s (about 16ms) (including physical engine, game state, rendering, AI, etc.). In a game, there are usually a large number of (10000, 100000) game objects that need to update the state. Except that the rendering can rely on GPU, other logic needs to be completed by CPU, and even most of it can only be completed by single thread. As a result, CPU (mainly the bandwidth from memory to CPU) will become a bottleneck in most complex scenes. In the era of almost no increase in CPU single core speed, how to improve the efficiency of CPU processing is the core of improving game performance.
  • Code organization: like the case in the first chapter, when we use the traditional OOP model for game development, it is easy to fall into the problem of code organization, which eventually makes the code difficult to read, maintain and optimize.
  • Scalability: This is similar to the previous one, but it is more caused by the characteristics of the game: it needs to be updated quickly and add new elements. The architecture of a game needs to be able to add game elements through low code or even 0 code, so as to retain users through rapid update. If each change needs to develop new code, test, and then let users download the client again, it can be imagined that this kind of game is difficult to survive in the current competitive environment.

ECS architecture can solve the following problems:

  • Entity: used to represent any game object, but in ECS, the most important thing of an entity is its EntityID. An entity contains multiple components
  • Component: real data. ECS architecture divides Entity objects into more detailed components, such as location, material, status, etc., that is, an Entity is actually just a Bag of Components.
  • System (or ComponentSystem, component system): it is a real behavior. There can be many different component systems in a game. Each component system is only responsible for one thing and can deal with a large number of the same components in turn without understanding the specific Entity. Therefore, a ComponentSystem can theoretically have more efficient component processing efficiency, and even realize parallel processing, so as to improve CPU utilization.

Some core performance optimizations of ECS include putting components of the same type in the same Array, and then keeping Entity only to the pointer of their components, which can make better use of CPU cache, reduce data loading cost, and SIMD optimization.

The pseudo code of an ECS case is as follows:

public class Entity {
  public Vector position; // Here, Vector is a Component, pointing to movementsystem One in the list
}

public class MovementSystem {
  List< Vector> list;

  // System behavior
  public void update(float delta) {
    for(Vector pos : list) { // The CPU can be directly optimized with SIMD, and the performance is very high at the same time
      pos.x = pos.x + delta;
      pos.y = pos.y + delta;
    }
  }
}

@Test
public void test() {
  MovementSystem system = new MovementSystem();
  system.list = new List<>() { new Vector(0, 0) };
  Entity entity = new Entity(list.get(0));
  system.update(0.1);
  assertTrue(entity.position.x == 0.1);
}

Since this article does not explain the ECS architecture, interested students can search entity component system or look at Unity's ECS documents.

2. ECS architecture analysis

Come back and analyze ECS. In fact, its origin is still several very old concepts:

Componentization

In software systems, we usually divide complex large-scale systems into independent components to reduce complexity. For example, the cost of repeated development is reduced through front-end components in web pages, and the micro service architecture reduces the service complexity and system impact through the separation of services and databases. However, the ECS architecture takes this to the extreme, that is, componentization is implemented inside each object. By splitting the data and behavior of a game object into multiple components and component systems, the high reusability of components can be realized and the cost of repeated development can be reduced.

Behavioral withdrawal

This has an obvious advantage in the game System. According to OOP, a game object may include mobile code, combat code, rendering code, AI code, etc. if they are all placed in one class, it will be very long and difficult to maintain. By separating the general logic into a separate System class, the readability of the code can be significantly improved. Another advantage is that some dependencies unrelated to object code are removed, such as the above Delta. If this delta is placed in the update method of Entity, it needs to be injected as an input parameter, and it can be managed uniformly in the System. There is a question in the first chapter, which player should be Attack (monster) or monster receiveDamage(Weapon, Player). In ECS, this problem becomes very simple. Just put it in combat System.

Data driven

That is, the behavior of an object is not written dead, but determined by its parameters. Through the dynamic modification of parameters, the specific behavior of an object can be changed quickly. In the game architecture of ECS, the behavior and playing method of an object can be changed by registering the corresponding Component for the Entity and changing the combination of specific parameters of the Component. For example, creating a kettle + explosion attribute becomes an "explosion kettle", adding wind magic to a bicycle becomes a flying car, etc. In some Rougelike games, there may be more than 10000 items with different types and functions. If these items with different functions are coded separately, they may never be finished. However, through the data-driven + Component architecture, the configuration of all items is ultimately a table, and the modification is extremely simple. This is also an embodiment of the principle that combination is better than inheritance.

3. Defects of ECS

Although ECS has begun to emerge in the game industry, I found that ECs architecture has not been used in any large-scale commercial applications. There may be many reasons, including the fact that ECs is relatively new and we still don't understand it, the lack of commercially mature and available frameworks, and programmers are not able to adapt to the change of thinking from writing logical scripts to writing components. However, I think the biggest problem is that ECs emphasizes the separation of data / State and behavior in order to improve performance, and to reduce GC costs, Direct manipulation of data has reached an extreme. In business applications, data correctness, consistency and robustness should be the highest priority, while performance is only icing on the cake. Therefore, ECS is difficult to bring great benefits in business scenarios. However, this does not mean that we cannot learn from some breakthrough ideas of ECs, including componentization, separation of cross object behavior, and data-driven mode, which can also be well used in DDD.

A solution based on DDD architecture

1 domain object

Back to our original problem domain, let's split various objects from the domain layer:

Entity class

In DDD, the entity class contains ID and internal state. In this case, the entity class contains Player, Monster and weapon. Weapon is designed as an entity class because two weapons with the same name should exist at the same time, so they must be distinguished by ID. at the same time, weapon can also be expected to contain some states in the future, such as upgrade, temporary buff, durability, etc.

public class Player implements Movable {
    private PlayerId id;
    private String name;
    private PlayerClass playerClass; // enum
    private WeaponId weaponId; // (Note 1)
    private Transform position = Transform.ORIGIN;
    private Vector velocity = Vector.ZERO;
}

public class Monster implements Movable {
    private MonsterId id;
    private MonsterClass monsterClass; // enum
    private Health health;
    private Transform position = Transform.ORIGIN;
    private Vector velocity = Vector.ZERO;
}

public class Weapon {
    private WeaponId id;
    private String name;
    private WeaponType weaponType; // enum
    private int damage;
    private int damageType; // 0 - physical, 1 - fire, 2 - ice
}

In this simple case, we can use enum's PlayerClass and MonsterClass to replace the inheritance relationship. Later, we can also use the Type Object design pattern to achieve data-driven.

Note 1: because Weapon is an entity class, but Weapon can exist independently, and Player is not an aggregation root, Player can only save WeaponId, not directly point to Weapon.

Componentization of value objects

In the previous ECS architecture, there is a concept of MovementSystem that can be reused. Although you should not directly operate components or inherit common parent classes, you can componentize domain objects through interfaces:

public interface Movable {
    // Equivalent to component
    Transform getPosition();
    Vector getVelocity();

    // behavior
    void moveTo(long x, long y);
    void startMove(long velX, long velY);
    void stopMove();
    boolean isMoving();
}

// Concrete implementation
public class Player implements Movable {
    public void moveTo(long x, long y) {
        this.position = new Transform(x, y);
    }

    public void startMove(long velocityX, long velocityY) {
        this.velocity = new Vector(velocityX, velocityY);
    }

    public void stopMove() {
        this.velocity = Vector.ZERO;
    }

    @Override
    public boolean isMoving() {
        return this.velocity.getX() != 0 || this.velocity.getY() != 0;
    }
}

@Value
public class Transform {
    public static final Transform ORIGIN = new Transform(0, 0);
    long x;
    long y;
}

@Value
public class Vector {
    public static final Vector ZERO = new Vector(0, 0);
    long x;
    long y;
}

Note two points:

  • The Moveable interface does not have a Setter. The rule of an Entity is that its attributes cannot be changed directly, and the internal state must be changed through the Entity method. This ensures data consistency.
  • The advantage of abstract Movable is that, like ECS, some special general behaviors (such as moving in a large map) can be handled through unified System code, avoiding repeated work.

2 equipment behavior

Because we won't use the subclass of Player to decide what kind of Weapon can be equipped, this logic should be split into a separate class. This class is called Domain Service in DDD.

public interface EquipmentService {
    boolean canEquip(Player player, Weapon weapon);
}

In DDD, an Entity should not directly refer to another Entity or service, that is, the following code is wrong:

public class Player {
    @Autowired
    EquipmentService equipmentService; // BAD: can't directly rely on

    public void equip(Weapon weapon) {
       // ...
    }
}

The problem here is that an Entity can only retain its own state (or an object that is not an aggregate root). Any other object, whether or not it is obtained through dependency injection, will destroy the Entity's Invariance and is difficult to measure alone.

The correct way to introduce the parameter of double is by reference:

public class Player {

    public void equip(Weapon weapon, EquipmentService equipmentService) {
        if (equipmentService.canEquip(this, weapon)) {
            this.weaponId = weapon.getId();
        } else {
            throw new IllegalArgumentException("Cannot Equip: " + weapon);
        }
    }
}

Here, both Weapon and EquipmentService are passed in through method parameters to ensure that the Player's own state will not be polluted.

Double Dispatch is a method that is often used to use Domain Service, which is similar to call reversal.

Then implement relevant logical judgment in EquipmentService. Here we use another commonly used Strategy (or Policy) design pattern:

public class EquipmentServiceImpl implements EquipmentService {
    private EquipmentManager equipmentManager; 

    @Override
    public boolean canEquip(Player player, Weapon weapon) {
        return equipmentManager.canEquip(player, weapon);
    }
}

// Policy priority management
public class EquipmentManager {
    private static final List< EquipmentPolicy> POLICIES = new ArrayList<>();
    static {
        POLICIES.add(new FighterEquipmentPolicy());
        POLICIES.add(new MageEquipmentPolicy());
        POLICIES.add(new DragoonEquipmentPolicy());
        POLICIES.add(new DefaultEquipmentPolicy());
    }

    public boolean canEquip(Player player, Weapon weapon) {
        for (EquipmentPolicy policy : POLICIES) {
            if (!policy.canApply(player, weapon)) {
                continue;
            }
            return policy.canEquip(player, weapon);
        }
        return false;
    }
}

// Strategy case
public class FighterEquipmentPolicy implements EquipmentPolicy {

    @Override
    public boolean canApply(Player player, Weapon weapon) {
        return player.getPlayerClass() == PlayerClass.Fighter;
    }

    /**
     * Fighter Can equip Sword and Dagger
     */
    @Override
    public boolean canEquip(Player player, Weapon weapon) {
        return weapon.getWeaponType() == WeaponType.Sword
                || weapon.getWeaponType() == WeaponType.Dagger;
    }
}

// Other strategies are omitted. See the source code

The biggest advantage of this design is that the future rule increase only needs to add a new Policy class without changing the original class.

3 aggressive behavior

As mentioned above, it should be player Attack (Monster) or Monster receiveDamage(Weapon, Player)? In DDD, because this behavior may affect player, Monster and weapon, it belongs to cross entity business logic. In this case, it needs to be completed through a third-party Domain Service.

public interface CombatService {
    void performAttack(Player player, Monster monster);
}

public class CombatServiceImpl implements CombatService {
    private WeaponRepository weaponRepository;
    private DamageManager damageManager;

    @Override
    public void performAttack(Player player, Monster monster) {
        Weapon weapon = weaponRepository.find(player.getWeaponId());
        int damage = damageManager.calculateDamage(player, weapon, monster);
        if (damage > 0) {
            monster.takeDamage(damage); // (Note 1) change Monster in domain service
        }
        // Omit the possible impact of Player and Weapon
    }
}

Similarly, in this case, we can solve the calculation problem of damage through the Strategy design pattern:

// Policy priority management
public class DamageManager {
    private static final List< DamagePolicy> POLICIES = new ArrayList<>();
    static {
        POLICIES.add(new DragoonPolicy());
        POLICIES.add(new DragonImmunityPolicy());
        POLICIES.add(new OrcResistancePolicy());
        POLICIES.add(new ElfResistancePolicy());
        POLICIES.add(new PhysicalDamagePolicy());
        POLICIES.add(new DefaultDamagePolicy());
    }

    public int calculateDamage(Player player, Weapon weapon, Monster monster) {
        for (DamagePolicy policy : POLICIES) {
            if (!policy.canApply(player, weapon, monster)) {
                continue;
            }
            return policy.calculateDamage(player, weapon, monster);
        }
        return 0;
    }
}

// Strategy case
public class DragoonPolicy implements DamagePolicy {
    public int calculateDamage(Player player, Weapon weapon, Monster monster) {
        return weapon.getDamage() * 2;
    }
    @Override
    public boolean canApply(Player player, Weapon weapon, Monster monster) {
        return player.getPlayerClass() == PlayerClass.Dragoon &&
                monster.getMonsterClass() == MonsterClass.Dragon;
    }
}

It should be noted that the CombatService domain service here and the EquipmentService domain service in 3.2 are both domain services, but there are great differences in essence. The EquipmentService above provides more read-only policies and only affects a single object, so it can be displayed in player The equip method is injected through parameters. However, combaservice may affect multiple objects, so it cannot be called directly through parameter injection.

4 unit test

@Test
@DisplayName("Dragoon attack dragon doubles damage")
public void testDragoonSpecial() {
    // Given
    Player dragoon = playerFactory.createPlayer(PlayerClass.Dragoon, "Dart");
    Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "Soul Eater", 60);
    ((WeaponRepositoryMock)weaponRepository).cache(sword);
    dragoon.equip(sword, equipmentService);
    Monster dragon = monsterFactory.createMonster(MonsterClass.Dragon, 100);

    // When
    combatService.performAttack(dragoon, dragon);

    // Then
    assertThat(dragon.getHealth()).isEqualTo(Health.ZERO);
    assertThat(dragon.isAlive()).isFalse();
}

@Test
@DisplayName("Orc should receive half damage from physical weapons")
public void testFighterOrc() {
    // Given
    Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
    Weapon sword = weaponFactory.createWeaponFromPrototype(swordProto, "My Sword");
    ((WeaponRepositoryMock)weaponRepository).cache(sword);
    fighter.equip(sword, equipmentService);
    Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);

    // When
    combatService.performAttack(fighter, orc);

    // Then
    assertThat(orc.getHealth()).isEqualTo(Health.of(100 - 10 / 2));
}

The specific code is relatively simple and the explanation is omitted.

5 mobile system

Finally, there is another kind of Domain Service. Through componentization, we can actually implement the same System as ECS to reduce some repetitive Codes:

public class MovementSystem {

    private static final long X_FENCE_MIN = -100;
    private static final long X_FENCE_MAX = 100;
    private static final long Y_FENCE_MIN = -100;
    private static final long Y_FENCE_MAX = 100;

    private List< Movable> entities = new ArrayList<>();

    public void register(Movable movable) {
        entities.add(movable);
    }

    public void update() {
        for (Movable entity : entities) {
            if (!entity.isMoving()) {
                continue;
            }

            Transform old = entity.getPosition();
            Vector vel = entity.getVelocity();
            long newX = Math.max(Math.min(old.getX() + vel.getX(), X_FENCE_MAX), X_FENCE_MIN);
            long newY = Math.max(Math.min(old.getY() + vel.getY(), Y_FENCE_MAX), Y_FENCE_MIN);
            entity.moveTo(newX, newY);
        }
    }
}

Single test:

@Test
@DisplayName("Moving player and monster at the same time")
public void testMovement() {
    // Given
    Player fighter = playerFactory.createPlayer(PlayerClass.Fighter, "MyFighter");
    fighter.moveTo(2, 5);
    fighter.startMove(1, 0);

    Monster orc = monsterFactory.createMonster(MonsterClass.Orc, 100);
    orc.moveTo(10, 5);
    orc.startMove(-1, 0);

    movementSystem.register(fighter);
    movementSystem.register(orc);

    // When
    movementSystem.update();

    // Then
    assertThat(fighter.getPosition().getX()).isEqualTo(2 + 1);
    assertThat(orc.getPosition().getX()).isEqualTo(10 - 1);
}

Here, MovementSystem is a relatively independent Domain Service. Through the Componentization of Movable, it realizes the centralization of similar codes and the centralization of some general dependencies / configurations (such as X and Y boundaries).

IV. some design specifications of DDD domain layer

For the same example, I compared three implementations of OOP, ECS and DDD as follows:

  • OOP code based on inheritance relationship: OOP code is best written and easy to understand. All rule codes are written in objects, but when domain rules become more and more complex, its structure will limit its development. The new rules may lead to the overall refactoring of the code.
  • Component based ECS Code: ECS code has the highest flexibility, reusability and performance, but it greatly weakens the cohesion of entity classes. All business logic is written in the service, which will lead to the inability to guarantee the business consistency and have a great impact on the business system.
  • DDD architecture based on domain objects + domain services: the rules of DDD are actually the most complex. At the same time, we should consider the cohesion and Invariants of entity classes, the ownership of cross object rule codes, and even the invocation mode of specific domain services. The understanding cost is relatively high.

So next, I will try to reduce the design cost of DDD domain layer through some design specifications.

1 Entity class

The core of most DDD architectures is the Entity class, which contains the state in a domain and the direct operation of the state. The most important design principle of Entity is to ensure the Invariants of the Entity, that is, to ensure that no matter how the external operation is, the internal attributes of an Entity cannot conflict with each other and the state is inconsistent. Therefore, several design principles are as follows:

Create consistent

In the anemia model, the commonly seen code is the assignment of one parameter by one parameter by the caller after a model is manually new, which is easy to cause omission and lead to inconsistent entity states. Therefore, there are two methods to create entities in DDD:

1) The constructor parameter should contain all necessary attributes or have reasonable default values in the constructor

For example, account creation:

public class Account {
    private String accountNumber;
    private Long amount;
}

@Test
public void test() {
    Account account = new Account();
    account.setAmount(100L);
    TransferService.transfer(account); // An error is reported because the Account lacks the necessary AccountNumber
}

Without a strong validation constructor, the consistency of the created entities cannot be guaranteed. Therefore, it is necessary to add a strong verification constructor:

    public Account(String accountNumber, Long amount) {
        assert StringUtils.isNotBlank(accountNumber);
        assert amount >= 0;
        this.accountNumber = accountNumber;
        this.amount = amount;
    }
}

@Test
public void test() {
    Account account = new Account("123", 100L); // Ensure the validity of objects
}

2) Use Factory mode to reduce caller complexity

Another method is to create objects through Factory mode to reduce some repetitive input parameters. For example:

public class WeaponFactory {
    public Weapon createWeaponFromPrototype(WeaponPrototype proto, String newName) {
        Weapon weapon = new Weapon(null, newName, proto.getWeaponType(), proto.getDamage(), proto.getDamageType());
        return weapon;
    }
}

By passing in an existing Prototype, you can quickly create a new entity. There are other design patterns, such as Builder, which are not pointed out one by one.

Try to avoid public setter s

One of the most likely reasons for inconsistency is that the entity exposes the public setter method, especially when a single set parameter will lead to inconsistent states. For example, an order may contain sub entities such as order status (order placed, paid, shipped and received), payment document and logistics document. If a caller can set the order status at will, it may lead to mismatching between the order status and the sub entity, resulting in the impassability of the business process. Therefore, in the entity, the internal state needs to be modified through behavioral methods:

@Data @Setter(AccessLevel.PRIVATE) // Ensure that public setter s are not generated
public class Order {
    private int status; // 0 - create, 1 - pay, 2 - ship, 3 - receive
    private Payment payment; // Payment order
    private Shipping shipping; // Logistics order

    public void pay(Long userId, Long amount) {
        if (status != 0) {
            throw new IllegalStateException();
        }
        this.status = 1;
        this.payment = new Payment(userId, amount);
    }

    public void ship(String trackingNumber) {
        if (status != 1) {
            throw new IllegalStateException();
        }
        this.status = 2;
        this.shipping = new Shipping(trackingNumber);
    }
}
[suggestion] in some simple scenarios, sometimes you can set a value at will without causing inconsistency. It is also suggested to rewrite the method name as a more "behavioral" name to enhance its semantics. For example, setPosition(x, y) can be called moveTo(x, y), setAddress can be called assignAddress, etc.

Ensure the consistency of master and child entities through aggregation roots

In a slightly more complex domain, the main entity usually contains sub entities. At this time, the main entity needs to play the role of aggregation root, that is:

  • The child entity cannot exist alone and can only be obtained by aggregating the root. No external object can directly retain references to child entities
  • The sub entity does not have an independent Repository and cannot be saved or taken out separately. It must be instantiated through the Repository of the aggregation root
  • A sub entity can modify its own state separately, but the state consistency between multiple sub entities needs to be guaranteed by aggregation roots

Common cases of aggregation in e-commerce domain, such as master sub order model, commodity / SKU model, cross sub order discount, cross store discount model, etc. Many aggregation roots and Repository design specifications have been explained in detail in my previous article on Repository, which can be used for reference.

You cannot strongly rely on other aggregation root entities or domain services

The principle of an entity is high cohesion and low coupling, that is, an entity class cannot directly rely on an external entity or service internally. There is a serious conflict between this framework and most ORM development principles. The necessary reasons for this principle include: the dependence on external objects will directly lead to the inability of entities to be tested; And an entity cannot guarantee that the change of an external entity will not affect the consistency and correctness of its own entity.

Therefore, there are two correct methods of external dependence:

  • Save only the IDs of external entities: here again, I strongly recommend using strongly typed ID objects instead of Long IDs. Strongly typed ID objects can not only include self verification code to ensure the correctness of ID value, but also ensure that various input parameters will not bug due to the change of parameter order. For details, please refer to my Domain Primitive article.
  • For external dependencies with "no side effects", they are passed in by means of method parameters. For example, the equipment (wepon, EquipmentService) method mentioned above.

If a method has side effects on external dependencies, it can only be solved through Domain Service instead of method parameters, as shown below.

The actions of any entity can only directly affect the entity (and its subsidiaries)

This principle is more a principle to ensure the readability and comprehensibility of the code, that is, the behavior of any entity cannot have "direct" side effects ", that is, directly modify other entity classes. The advantage of this is that the code will not cause accidents when it is read.

Another reason for compliance is to reduce the risk of unknown changes. In a system, all changes of an entity object should be expected. If an entity can be modified directly by the outside at will, it will increase the risk of code bug s.

2 Domain Service

As mentioned above, there are many kinds of domain services. Here, three common services are summarized according to the above:

Single object strategy

This domain object mainly aims at the change of a single entity object, but it involves several domain objects or some rules of external dependencies. In the above, EquipmentService is such:

  • The object to be changed is the Player parameter
  • It reads the data of Player and Weapon, and may also read some data from the outside

In this type, the entity should pass in the domain service through method input, and then reverse the method calling the domain service through Double Dispatch, such as:

Player.equip(Weapon, EquipmentService) {
    EquipmentService.canEquip(this, Weapon);
}

Why can't you call the domain service first and then the method of the entity object, so as to reduce the entity's dependency on the domain service? For example, the following method is wrong:

boolean canEquip = EquipmentService.canEquip(Player, Weapon);
if (canEquip) {
    Player.equip(Weapon); // ❌, This method is not feasible because it has the possibility of inconsistency
}

The main reason for the error is the lack of domain services, which may lead to inconsistent methods.

Cross object transactional

When an entity's behavior must be modified by a single method, it can't be directly processed by a single method. Here, domain services play a more cross object transaction role to ensure consistency between changes of multiple entities.

In the above, although the following codes can be passed, they are not recommended:

public class Player {
    void attack(Monster, CombatService) {
        CombatService.performAttack(this, Monster); // ❌, Don't write that. It will cause side effects
    }
}

In fact, we should directly call the method of CombatService:

public void test() {
    //...
    combatService.performAttack(mage, orc);
}

This principle also reflects the principle that "the behavior of any entity can only directly affect its own entity (and its sub entities)", that is, player Attack will directly affect Monster, but Monster is not aware of this call.

General component type

This type of domain service is more like the System in ECS. It provides componentized behavior, but it is not directly bound to an entity class. For specific cases, please refer to the implementation of MovementSystem above.

3. Domain Policy

Policy or Strategy design pattern is a general design pattern, but it often appears in DDD architecture. Its core is to encapsulate domain rules.

A Policy is a stateless singleton object, which usually requires at least two methods: canApply and a business method. The canApply method is used to determine whether a Policy is applicable to the current context. If applicable, the caller will trigger the business method. Generally, in order to reduce the testability and complexity of a Policy, the Policy should not directly operate the object, but operate the object in the Domain Service by returning the calculated value.

In the above case, DamagePolicy is only responsible for calculating the damage that should be suffered, rather than directly causing damage to Monster. In addition to being testable, it also prepares for the future multi Policy superposition calculation.

In addition to the static injection of multiple policies and manual prioritization in this article, we can often see the registration of policies through Java SPI mechanism or SPI like mechanism and the sorting of policies through different Priority schemes in daily development. We won't do much here.

V. handling methods of side effects - domain events

In the above, there is a type of domain rule that I deliberately ignored, which is "side effect". The general side effect occurs after the state of the core domain model changes, and the impact or behavior of synchronization or asynchrony on another object. In this case, we can add a side effect rule:

When Monster's health is reduced to 0, Player will be rewarded with experience

There are many solutions to this problem, such as directly writing side effects in CombatService:

public class CombatService {
    public void performAttack(Player player, Monster monster) {
        // ...
        monster.takeDamage(damage);
        if (!monster.isAlive()) {
            player.receiveExp(10); // Received experience
        }
    }
}

But the problem is that the code of CombatService will become very complex soon. For example, we add another side effect:

When the exp of Player reaches 100, it will be upgraded one level

Then our code will become:

public class CombatService {
    public void performAttack(Player player, Monster monster) {
        // ...
        monster.takeDamage(damage);
        if (!monster.isAlive()) {
            player.receiveExp(10); // Received experience
            if (player.canLevelUp()) {
                player.levelUp(); // upgrade
            }
        }
    }
}

What if you add "reward XXX after upgrading"? "Update XXX ranking"? By analogy, the subsequent code will not be maintainable. So we need to introduce the last concept of domain layer: Domain Event.

1. Introduction to field events

Domain event is a notification mechanism that is expected to be perceived by other objects in the domain after something happens in the domain. In the above case, the fundamental reason why the code becomes more and more complex is that the response code (such as upgrade) is directly coupled with the above event trigger conditions (such as received experience), and this coupling is implicit. The advantage of domain events is to "manifest" this hidden side effect. Through an explicit event, the event trigger and event processing are decoupled, and finally the purpose of clearer code and better scalability is achieved.

Therefore, domain events are the cross entity "side effect" propagation mechanism recommended in DDD.

2. Domain event implementation

Unlike Message Queuing Middleware, domain events are usually executed immediately, in the same process, and may be synchronous or asynchronous. We can implement the notification mechanism in the process through an EventBus. The simple implementation is as follows:

// Implementer: Yujin November 28, 2019
public class EventBus {

    // Register
    @Getter
    private final EventRegistry invokerRegistry = new EventRegistry(this);

    // Event distributor
    private final EventDispatcher dispatcher = new EventDispatcher(ExecutorFactory.getDirectExecutor());

    // Asynchronous event distributor
    private final EventDispatcher asyncDispatcher = new EventDispatcher(ExecutorFactory.getThreadPoolExecutor());

    // Event distribution
    public boolean dispatch(Event event) {
        return dispatch(event, dispatcher);
    }

    // Asynchronous event distribution
    public boolean dispatchAsync(Event event) {
        return dispatch(event, asyncDispatcher);
    }

    // Internal event distribution
    private boolean dispatch(Event event, EventDispatcher dispatcher) {
        checkEvent(event);
        // 1. Get event array
        Set< Invoker> invokers = invokerRegistry.getInvokers(event);
        // 2. An event can be monitored N times, regardless of the call result
        dispatcher.dispatch(event, invokers);
        return true;
    }

    // Event bus registration
    public void register(Object listener) {
        if (listener == null) {
            throw new IllegalArgumentException("listener can not be null!");
        }
        invokerRegistry.register(listener);
    }

    private void checkEvent(Event event) {
        if (event == null) {
            throw new IllegalArgumentException("event");
        }
        if (!(event instanceof Event)) {
            throw new IllegalArgumentException("Event type must by " + Event.class);
        }
    }
}

Calling method:

public class LevelUpEvent implements Event {
    private Player player;
}

public class LevelUpHandler {
    public void handle(Player player);
}

public class Player {
    public void receiveExp(int value) {
        this.exp += value;
        if (this.exp >= 100) {
            LevelUpEvent event = new LevelUpEvent(this);
            EventBus.dispatch(event);
            this.exp = 0;
        }
    }
}
@Test
public void test() {
    EventBus.register(new LevelUpHandler());
    player.setLevel(1);
    player.receiveExp(100);
    assertThat(player.getLevel()).equals(2);
}

3 defects and prospects of current field events

It can be seen from the above code that the good implementation of domain events depends on the framework level support of EventBus, Dispatcher and Invoker. At the same time, another problem is that Entity cannot directly rely on external objects, so EventBus can only be a global Singleton at present, and everyone should know that global Singleton objects are difficult to be tested alone. This easily leads to the fact that the Entity object cannot be easily covered by a complete single test.

Another solution is to invade an Entity and add a List for each Entity:

public class Player {
  List< Event> events;
  
  public void receiveExp(int value) {
        this.exp += value;
        if (this.exp >= 100) {
            LevelUpEvent event = new LevelUpEvent(this);
            events.add(event); // Add event
            this.exp = 0;
        }
    }
}

@Test
public void test() {
    EventBus.register(new LevelUpHandler());
    player.setLevel(1);
    player.receiveExp(100);
  
    for(Event event: player.getEvents()) { // Here is the explicit dispatch event
        EventBus.dispatch(event);
    }
  
    assertThat(player.getLevel()).equals(2);
}

However, it can be seen that this solution will not only invade the entity itself, but also need more verbose explicit dispatch events on the caller, which is not a good solution.

Maybe there will be a framework in the future that allows us to neither rely on global Singleton nor explicitly handle events, but the current schemes basically have more or less defects, which we can pay attention to in use.

Vi. summary

In the real business logic, our domain model has some "particularity" more or less. It may be tiring if we want to comply with DDD specification 100%, so the most important thing is to sort out the influence surface of an object behavior, and then make design decisions, that is:

  • Does it affect only a single object or multiple objects
  • Future expansibility and flexibility of the rules
  • performance requirement
  • Treatment of side effects, etc

Of course, most of the time, a good design is the choice of many factors. We need to have a certain accumulation and really understand the logic, advantages and disadvantages behind each architecture. The architect can not choose the best solution from multiple solutions, but the best one.

Original link
This article is the original content of Alibaba cloud and cannot be reproduced without permission.

Topics: entity