During pit filling. Thank you for your expectation of DDD series. You are welcome to pay attention to this account and check the follow-up content.
Address of the first part:
Address of Part II:
Part III content address:
In a DDD architecture design, the design rationality of the domain layer will directly affect the code structure of the whole architecture, as well as the design of the 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
Η 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 damage to physical attack halved
- The damage of elves to magic attacks is halved
- Dragons are immune to physical and magic attacks. Unless the player is a Dragon Rider, the damage is doubled
• 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 damage 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 redundant explanation.
Analyze the design defects 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, which 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, you can limit the setter to protected in the parent class, but this limits the API of the parent class, greatly reduces the flexibility, and 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 parent logic, 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 class Monster { 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 } }
Why is "try your best" recommended in a complex software The core reason not to violate OCP? Is that a change in existing logic may affect some original codes, resulting in some unforeseen effects. This risk can only be guaranteed through complete unit test coverage, but it is difficult to ensure single test coverage in actual development. OCP principles can avoid this risk as much as possible when new behaviors can only pass through new fields/ Method, the behavior of the old code will not change.
Although inheritance can Open for extension, it is difficult to achieve Closed for modification. Therefore, the main method 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 the logic in Monster's class. The main consideration is that Monster will be injured and reduce Health, but if Player holds one Will a double-edged sword hurt yourself at the same time? Do you find that there are problems writing in Monster class? What is the principle 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, and 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 simple intuitively, if your business is complex and a large number of business rules will be changed 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 pits 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.
Introduction to entity component system (ECS) architecture
ⅶ introduction to ECS
The ECS architecture model 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, the ECS 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 within 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 need to update their state. Except that 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, resulting in CPU (mainly memory to CPU bandwidth) in complex scenes most of the time It will become a bottleneck. In the era when the single core speed of CPU is almost no longer increased, how to improve the efficiency of CPU processing is the core of improving game performance.
- Code organization: like the case in Chapter 1, 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 due to 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.
The ECS architecture can solve the above problems. The ECS architecture is mainly divided into:
- Entity: it is 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) : 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 process 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 placing components of the same type in the same Array, and then retaining Entity only to the pointer of each component, 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:
Since this article does not explain the ECS architecture, interested students can search entity component system or look at Unity's ECS documents.
▐ ECS architecture analysis
Come back and analyze ECS. In fact, its origin is still several very old concepts:
Componentization
In software systems, we usually split complex large-scale systems into independent components to reduce complexity. For example, in web pages, we reduce the cost of repeated development through front-end componentization, and in microservice architecture, we reduce the service complexity and system impact through the splitting of services and databases. However, ECS architecture takes this to the extreme, that is, componentization is realized inside each object. Through By splitting the data and behavior of a game object into multiple components and component systems, it can realize the high reusability of components and reduce the cost of repeated development.
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 For example, if the delta is placed in the update method of an Entity, it needs to be injected as an input parameter, and if it is placed in the System, it can be managed uniformly. In Chapter 1, there is a question: should it be Player.attack(monster) or Monster.receiveDamage(Weapon, Player) . in ECS, this problem becomes very simple. Just put it in the 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.
▐ Defects of ECS
Although ECS has begun to emerge in the game industry, I find that ECs architecture has not been used in any large commercial application. There may be many reasons, including that ECs is relatively new and we don't understand it, 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 come to an extreme. In commercial applications, data correctness, consistency and robustness should be the highest priority, and 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 can not 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
▐ 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 properties 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 to avoid repeated work.
▐ Equipment behavior
Because we can no longer use the subclass of Player to determine 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: cannot directly depend 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 reference method is to import (Double Dispatch) through method parameters:
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 future rule additions only need to add new Policy classes without changing the original classes.
▐ aggressive behavior
As mentioned above, should it 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 effects on 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 injected through parameters on the player.equipmethod. However, combaservice may affect multiple objects, so it cannot be called directly through parameter injection.
▐ unit testing
@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
▐ Mobile system
Finally, there is a 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).
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 code is written in objects, but its structure will limit its development when domain rules become more and more complex. 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 business consistency and have a great impact on commercial systems.
- 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. For the design specification of Value Object (Domain Primitive) in the domain layer, please refer to my previous article.
▐ Entity class
The core of most DDD architectures is the Entity class, which contains the state in a domain and the direct operation on 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 for creating entities in DDD:
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 is missing the necessary AccountNumber }
Without a strong validation constructor, the consistency of the created entities cannot be guaranteed. Therefore, a strong verification constructor needs to be added:
public class Account { 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 }
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 behavior methods:
@Data @Setter(AccessLevel.PRIVATE) // Ensure that the public setter is 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. It can only be obtained through the aggregation root method. No external object can directly retain references to child entities
- The child entity does not have an independent Repository and cannot be saved or retrieved separately. It must be instantiated through the Repository of the aggregation root
- Child entities can modify their own state separately, but the state consistency between multiple child entities needs to be guaranteed by aggregation roots
Common aggregation cases 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. This principle is in serious conflict with most ORM frameworks, so it needs special attention in the development process. The necessary reasons for this principle include: the dependence on external objects will directly cause the entity to be untested; 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 arguments. For example, the equipment (wepon, EquipmentService) method 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 that it can reduce the risk of unknown changes. In a system, all change operations of an entity object should be expected. If an entity can be directly modified externally at will, it will increase the risk of code bug s.
▐ Domain Service
As mentioned above, there are many kinds of domain services. Here, three common services are summarized according to the above:
Single object policy
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 by means of method parameters, 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 service participation, which may lead to inconsistent methods.
Cross object transactional
When a behavior will directly modify multiple entities, it can no longer be processed through the method of a single entity, but must be operated directly using the method of domain services. 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 of 4.1.5, that is, Player.attack will directly affect Monster, but this call to Monster is not perceived.
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, refer to the MovementSystem implementation above.
▐ Policy object (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, this also prepares for future multi Policy overlay calculations.
In addition to the static injection of multiple policies and manual prioritization in this article, it is often seen in daily development that policies are registered through Java SPI mechanism or SPI like mechanism, and policies are sorted through different Priority schemes. I won't do much here.
Meal addition - treatment of side effects - domain events
In the above, I deliberately ignored one type of domain rule, which is "side effect". The general side effect occurs after the state of the core domain model changes, and the synchronous or asynchronous impact or behavior 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 soon become very complex. 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 } } } }
If you add "reward XXX after upgrading" and "update XXX ranking", and so on, the subsequent codes will not be maintained. Therefore, we need to introduce the last concept of the domain layer: Domain Event.
▐ Introduction to domain events
Domain event is a notification mechanism that you want other objects in the domain to be aware of 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) directly triggers the above event conditions (such as experience received) Direct coupling, and this coupling is implicit. The advantage of domain events is to make this implicit side effect "explicit". 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 recommended cross entity "side effect" propagation mechanism in DDD.
▐ 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 in-process notification mechanism through an EventBus, which is simple 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); } } }
Call 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); }
▐ 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 the global Singleton object is difficult to be tested. This can easily lead to Entity Objects 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 require 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.
summary
In the real business logic, our domain models are more or less "special". It may be tiring if 100% of them meet the DDD specification. Therefore, the most important thing is to sort out the influence surface of an object behavior, and then make design decisions, that is:
- Whether it affects only a single object or multiple objects,
- The future expansibility and flexibility of the rules,
- Performance requirements,
- Treatment of side effects, etc
Of course, many times 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. A good architect does not have a correct answer, but can choose the most balanced solution from multiple solutions.