上一次,我们聊了DDD的战略设计,这一篇我们从技术实现的角度聊聊DDD的战术设计。
战术设计:DDD 的要点
实现领域对象之间的关联并描述它们的功能似乎很容易,但是应该以清晰和直观的方式正确地区分它们的意义和存在理由。 DDD提出了一组构造和模式来实现它。
实体(Entities)
具有独特身份并具有连续性的对象被称为实体(Entities),它们不仅仅由它们的属性来定义,更多的是由它们是谁来定义。 它们的属性可能会发生变化,它们的生命周期可能会发生巨大的变化,但它们的身份却一直存在。 通过惟一的键或保证惟一的属性组合来维护标识。
例如在电子商务领域,一个订单有一个唯一的标识符,它经历了几个不同的阶段:打开、确认、发货等,因此它被认为是一个域实体。
export class Customer {
private id: number;
private name: string;
protected constructor(name: string) {
// A uuid guarantees a unique identity for the Customer Entity
this.id = uuidv4();
this.name = this.setName(name);
}
private setName(name: string): string {
// Business invariant: Customer name should not be empty
if (name === undefined || name === '') {
throw new Error('Name cannot be empty');
}
return name;
}
public static create(name: string): Customer {
return new Customer(name);
}
}
值对象(Value Objects)
描述特征且不具有任何唯一标识的对象称为值对象,它们只关心它们是什么,而不是它们是谁。
值对象是属性,可以被多个实体共享,例如:两个客户可以有相同的送货地址。 尽管存在风险——如果其中一个属性需要更改,共享这些属性的所有实体都将受到影响。 为了防止这种情况,值对象必须是不可变的,这迫使系统在需要更新时用新的实例替换它们。
此外,值对象的创建应该始终依赖于创建它们的数据的有效性,以及它如何遵守业务的不变量。 因此,如果数据无效,则不会创建对象实例。 例如,在北美,带有非字母数字字符的邮政编码将违反业务不变式,并将在Address创建时触发异常。
export class Address {
private readonly streetAddress: string;
private readonly postalCode: string
protected constructor(streetAddress: string, postalCode: string) {
this.streetAddress = this.getValidStreetAddress(streetAddress);
this.postalCode = this.getValidPostalCode(postalCode);
}
private getValidStreetAddress(streetAddress: string): string {
// Business invariant: street address should not be longer than 128 characters
if (streetAddress.length > 128) {
throw new Error('Address should not be longer than 128 characters');
}
return streetAddress;
}
private getValidPostalCode(postalCode: string): string {
// Business invariant: Should be a valid canadian postal code
const pattern = /[a-z]\d[a-z][ \-]?\d[a-z]\d/g;
if (!postalCode.match(pattern)) {
throw new Error('Postal code should only contain alphanumeric caracters and spaces');
}
return postalCode;
}
public getStreetAddress(): string {
return this.streetAddress;
}
public getPostalCode(): string {
return this.postalCode;
}
public static create(streetAddress: string, postalCode: string): Address {
return new Address(streetAddress, postalCode);
}
public equals(otherAddress: Address): boolean {
// Value Objects equality is based on their propertie's values
return objectHelper.isEqual(this, otherAddress);
}
}
服务(Services)
在许多情况下,域模型需要与实体或值对象进行不直接相关的某些操作,将这些操作强制写到其实现代码中会导致对其定义的扭曲。 服务是提供无状态操作的类。 它们通常被命名为动词,而不是实体和值对象的名词,并基于泛在语言命名。
服务应该精心设计,始终确保它们不会剥夺实体和值对象的直接责任和行为。 它们还应该是无状态的,这样客户端就可以在应用程序的生命周期中使用任何给定的服务实例而不考虑该实例的历史。 拥有没有领域逻辑的实体和值对象被认为是一种反模式,称为贫血域模型。
领域对象及其生命周期(Domain Objects and their Life Cycle)
域对象通常具有复杂的生命周期,它们被实例化、经历多次更改、与其他对象交互、执行操作、持久化、重构、删除等等。保持其完整性的同时确保系统不会对其复杂的生命周期管理不善,这是实施适当的领域模型所代表的主要挑战之一。
在复杂的业务域中域对象之间的关系及与模型之间的关系是很难维护的,就像 Eric Evans 所描述的那样:
“在具有复杂关联的模型中,很难保证对象更改的一致性。需要维护适用于密切相关的对象组的不变量,而不仅仅是离散对象。然而谨慎的锁定方案会导致多个用户相互无意义地干扰并使系统无法使用”
聚合体(Aggregates)
为了减轻上述挑战,需要聚合实体和值对象,以限制业务不变量的违反。
聚合是相关实体和值对象的集合,聚集在一起表示事务边界。 每个聚合都有一个面向外部的实体,它控制对边界内对象的所有访问,这个实体称为聚合根,它是其他对象可以交互的唯一对象。 聚合中的任何对象都不能从外部直接调用,从而保持内部的一致性。
业务不变量是保证聚合及其内容完整性的业务规则,换句话说,它是一种确保其状态始终与业务规则一致的机制。 例如,当某个产品的库存数量为零时,就不能下订单。
export class Order {
private id: number;
private isConfirmed: boolean;
private total: number;
private shippingAddress: Address;
private customer: Customer;
private items: Product[];
private payments: Payment[];
constructor(
customer: Customer,
shippingAddress: Address,
items: Item[],
payments: Payment[]
) {
// Generate a unique identifier (UUID) for the Order Entity
this.id = uuidv4();
this.isConfirmed = false;
this.total = 0;
this.customer = customer;
this.shippingAddress = shippingAddress;
this.items = items.length ? items : [];
this.payments = payments.length ? payments : [];
}
private getPaymentsTotal(): number {
return this.payments.reduce((accumulator, payment) => accumulator + payment.total);
}
public addPayments(payment: Payment): void {
this.payments.push(payment);
this.total += payment.total;
}
public addItems(product: Product): void {
// Business invariant: an order should not have items which are not in stock
if (!product.getStockQuanity()) {
throw new Error(`No stock for product id: ${product.id}`);
}
this.items.push(product);
}
public confirm(): void {
// Business invariant: only fully paid orders can be confirmed
if (this.total === this.getPaymentsTotal()) {
throw new Error('Total amount paid does not equal order total');
}
this.isConfirmed = true;
}
}
工厂(Factories)
创建复杂的对象和聚合实例可能是一项艰巨的任务,而且还可能泄露太多的对象内部细节。 使用工厂,我们可以解决这个问题,并提供必要的封装。
Factory应该能够在一个原子操作中构造域对象或聚合,要求客户端在调用时提供所需的所有数据,并在创建的对象上强制执行所有不变量。 此活动不是域模型的一部分,但仍然属于域层,因为它是应用于系统的业务规则的一部分。
export class OrderFactory implements Factory {
private customerEntity: Customer;
private addressValue: Address;
private productsRepository: Repository;
private paymentsRepository: Repository;
constructor(customerEntity: Customer, addressValue: Address, productsRepository: Repository, paymentsRepository: Repository) {
this.customerEntity = customerEntity;
this.addressValue = addressValue;
this.productsRepository = productsRepository;
this.paymentsRepository = paymentsRepository;
}
public async createOrder(customerName: string, addressDto: AddressDto, itemDtos: ItemDto[], paymentDtos: PaymentDto[]): Order {
try {
const customer = this.customerEntity.create(customerName);
const shippingAddress = this.addressValue.create(addressDto.streetAddress, addressDto.postalCode);
const items = await this.productsRepository.getProductCollection(itemDtos);
const payments = await this.paymentsRepository.getPaymentCollection(paymentDtos);
return new Order(customer, shippingAddress, items, payments);
} catch(err) {
// Error handling logic should go here
throw new Error(`Order creation failed: ${err.message}`);
}
}
}
存储库(Repositories)
为了能够从我们的持久性中检索对象,无论是在内存中、文件系统中还是数据库中,我们需要提供一个接口来向客户端隐藏实现细节,这样它就不再依赖于基础设施的细节,而仅仅依赖于一种抽象。
存储库提供了一个接口,领域层可以使用它来检索存储的对象,避免与存储逻辑的紧密耦合,并给客户端一种直接从内存中检索对象的错觉。
值得一提的是,所有存储库接口定义都应位于领域层中,但它们的具体实现属于基础结构层。
export class OrderRepository implements Repository {
private model: OrderModel;
private mapper: OrderMapper;
private productsRepository: Repository;
private paymentsRepository: Repository;
constructor(orderModel: OrderModel, orderMapper: Mapper, productsRepository: Repository, paymentsRepository: Repository) {
this.model = orderModel;
this.mapper = orderMapper;
this.productsRepository = productsRepository;
this.paymentsRepository = paymentsRepository;
}
public async getById(orderId: number): Promise<Order> {
const order = await this.model.findOne(orderId);
if (!order) {
throw new Error(`No order found with order id: ${orderId}`);
}
return this.mapper.toDomain(order);
}
public async save(order: Order): Promise<Boolean> {
const orderRecord: OrderRecord = this.mapper.toPersistence(order);
try {
await this.productsRepository.insert(order.items);
await this.paymentsRepository.insert(order.payments);
if (!!await this.getById(order.id)) {
await this.model.update(orderRecord);
} else {
await this.model.insert(orderRecord);
}
} catch (err) {
// call to rollback mechanism should go here
return false;
}
return true;
}
}
将领域与其他问题隔离
为专门解决领域问题而编写的代码部分是整个代码库的一小部分。如果这部分与解决其他问题的代码交织在一起,将很难理解和改进。将领域逻辑与其他功能清楚地分开将减少泄漏并避免在大型复杂系统中造成混淆。
DDD 提出了一种分层架构,通过将代码库分为 4 个主要层:用户接口层、应用程序层、领域层和基础设施层来分离关注点并避免责任混淆。
这里的主要规则是每层中的组件应该只依赖于同一层或它下面的任何层中的组件。上层可以通过调用它们的公共接口来使用下层的组件,而下层只能通过控制反转(IoC)向上通信。
- 用户接口层(User Interface Layer):负责显示数据和捕获用户的命令。
- 应用层(Application Layer):作为领域工作的编排器,它不知道领域规则,但组织和委托领域对象来完成它们的工作。它也是其他有界上下文可访问的唯一层。
- 领域层(Domain Layer):包含业务逻辑和规则,以及业务状态。它是领域模型所在的地方。
- 基础设施层(Infrastructure Layer):实现应用程序支持更高层、持久性、消息传递、层间通信等所需的所有技术功能……
尽管并非每个系统都需要所有层,但领域层的存在是 DDD 的先决条件。
结论
总而言之,DDD 是一种通过与领域专家的密切协作和严格的设计模式来解决业务问题的整体方法,它不是所有软件项目的通用解决方案,但如果应用得当,则可以很好的把控项目。