這篇文章將要來談談,目前軟體工程中,我自已覺得幫助很大的一個東西是 Domain Model,接下來將談談實作應用時我們會如何使用,還有搭配什麼東西 ~
根據 《This pattern is part of Patterns of Enterprise Application Architecture》的定義如下 :
An object model of the domain that incorporates both behavior and data.
然後它可以解什麼事情呢 ? 如果說我覺得最有感的應該就是 :
那就是資料庫有某個欄位,如果別人問這個欄位什麼時後會變化呢 ? 你要如何追 ?
然後有了 Domain Model 就可以達到以下的事情 :
所有業務欄位的狀態變化都封裝在這個 model 中,也就是說你看這個 model 就知道什麼情況下會 status 會變成 PAID。
! 注意雖然有感是那樣,但是 domain model 與資料庫 model 不要混在一起
雖然說資料庫欄位和 domain 欄位不一定是強綁定,但是會有關聯,所以如果有用 domain model 的話流程會是 :
然後如果沒有這個機制的話,每個地方都是直接 update 資料這個欄位,你應該會花很多時間找,而且如果這個欄位名很通用,並且 table 也用在很多地方,你應該會幹掉問你問題的人。
簡單的說,用了 domain model 整體的維護性,會增加不少。( 我指複雜業務變動,而不是 query 的業務 )
~小備註~
上面那種每個地方都用 update 啥的,事實上就是 CRUD 的寫法,通常只讓人寫的很快、很直覺,但是問題就在接下來維護的人都會罵乾。
以下為 domain model 的簡單範例,可以注意到一個重點:
這個 model 裡的欄位變化,都只會在這個 model 裡面
class Order {
private items: Map<string, OrderItem> = new Map();
private status: OrderStatus = OrderStatus.PENDING;
constructor(public readonly id: string) {
if (!id) {
throw new Error("Order must have a valid ID");
}
}
addProduct(product: Product, quantity: number): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error("Cannot modify an order that is not in PENDING status.");
}
const existingItem = this.items.get(product.id);
if (existingItem) {
existingItem.quantity += quantity;
} else {
this.items.set(product.id, new OrderItem(product, quantity));
}
}
removeProduct(productId: string): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error("Cannot modify an order that is not in PENDING status.");
}
if (!this.items.has(productId)) {
throw new Error("Product not found in the order");
}
this.items.delete(productId);
}
pay(): void {
if (this.status !== OrderStatus.PENDING) {
throw new Error("Order can only be paid if it's in PENDING status.");
}
this.status = OrderStatus.PAID;
}
ship(): void {
if (this.status !== OrderStatus.PAID) {
throw new Error("Order must be PAID before it can be shipped.");
}
this.status = OrderStatus.SHIPPED;
}
cancel(): void {
if (this.status === OrderStatus.COMPLETED || this.status === OrderStatus.CANCELLED) {
throw new Error("Cannot cancel a completed or already cancelled order.");
}
this.status = OrderStatus.CANCELLED;
}
}
實務上它通常會搭配 repository 一起使用,也就是說 repository 回傳的就是 domain model,如下範例,我們以底層為 mongoose 為例,然後總共有幾個元件。
以下為範例碼,就大概是長這個樣子。
interface OrderItemDoc {
orderId: string;
productId: string;
productName: string;
productPrice: number;
quantity: number;
}
enum OrderStatus {
PENDING = "PENDING",
PAID = "PAID",
SHIPPED = "SHIPPED",
COMPLETED = "COMPLETED",
CANCELLED = "CANCELLED"
}
interface OrderDoc {
id: string;
status: OrderStatus;
}
interface OrderRepository {
save(order: Order): Promise<void>;
findAll(): Promise<Order[]>;
}
class MongoOrderRepository implements OrderRepository {
constructor(
@InjectModel('Order') private readonly orderModel: Model<OrderModel>,
@InjectModel('OrderItem') private readonly orderItemModel: Model<OrderItemModel>,
) {}
async save(order: Order): Promise<void> {
const { orderDoc, orderItemsDocs } = OrderMapper.toPersistence(
order
);
const existingOrder = await this.orderModel.findOne({ id: order.id });
if (existingOrder) {
existingOrder.status = orderDoc.status;
await existingOrder.save();
} else {
await orderDoc.save();
}
await this.orderItemModel.deleteMany({ orderId: order.id });
await this.orderItemModel.insertMany(orderItemsDocs);
}
async findAll(): Promise<Order[]> {
const orderDocs = await this.orderModel.aggregate([
{
$lookup: {
from: 'orderitems',
localField: 'id',
foreignField: 'orderId',
as: 'items',
},
},
]);
const orders: Order[] = [];
for (const orderDoc of orderDocs) {
const order = OrderMapper.toDomain(orderDoc, orderDoc.items);
orders.push(order);
}
return orders;
}
}
class OrderMapper {
// 將 MongoDB 的 Order 和 OrderItem 模型轉換為領域模型
static toDomain(orderDoc: OrderModel, itemDocs: OrderItemModel[]): Order {
const order = new Order(orderDoc.id);
itemDocs.forEach((itemDoc) => {
const orderItem = new OrderItem(
itemDoc.productId,
itemDoc.productName,
itemDoc.productPrice,
itemDoc.quantity
);
order.addItem(orderItem);
});
return order;
}
// 將領域模型轉換為 MongoDB 的模型格式 (保存時使用)
static toPersistence(
order: Order
): {
orderDoc: OrderModel;
orderItemsDocs: OrderItemModel[];
} {
const orderDoc = new OrderModel({
id: order.id,
status: order.status,
});
const orderItemsDocs = order.getItems().map((item) => {
return new OrderItemModel({
orderId: order.id,
productId: item.product.id,
productName: item.product.name,
productPrice: item.product.price,
quantity: item.quantity,
});
});
return { orderDoc, orderItemsDocs };
}
}
就通常就在 service、usecase 那一層來呼叫。
先簡單說一下,active reocrd 可能想成就是『 ORM + 業務行為方法合在一起 』,例如下面範例。
const user = new UserModel({ name: 'Alice', email: 'alice@example.com' });
user.register();
await user.save();
但它不太適合複雜的情境。如果是簡單的情況下用 active reocrd 是沒啥問題,但複雜的情境不建議,因為會有以下幾個問題 :
所以通常如果是產品類的服務,基本上我 default 都傾向 repository,而不是 active reocrd。
大部份都是 query,而且其他操作都是 crud 那麼單純。
query 的情況下,也一樣要從 repository 拿出 domain model,然後回傳 domain model 的東西嗎 ? 這個會開一篇文章來寫,先簡單說一下不太建議。
對 ~ 但是我在實務上有碰過例外,我現在也沒啥好解法。
那就是 id。很多情況下我們的 id 都是會 save 完或啥後,db 會自動產生,所以目前除了 id 這個特殊會讓我用 set,其它情況下都一定只能在 domain model 的 method 中進行變化。
雖然有些人可能會說,那就先在 db 建個簡單的 data,然後就有 id 了,但有時後 db 我們也會有那些 require 欄位,所以也不算很好產生。
然後在某個地方維持 global id 也算是個方案,但問題就在於我們需要這樣做嗎 ? 好處是啥 ?
某些方面我覺得核心可以算是,但 aggregate 還有一些變化,後面會說。
這個我先打個問號,後面談到 ddd 與 aggregate 時會來說說 ~
大概在很久以前,我事實上有寫過這篇文章。我還記憶尤新,當初在寫時我發現我完全不知道他是在衝啥小,當純看 PoEAA 寫的我還是不太能理解,但是工作久了以後,碰到很多次我下面說的問題:
30-12 之 Domain Layer - Domain Model ( 未完成版 )
那就是資料庫有某個欄位,如果別人問這個欄位什麼時後會變化呢 ? 你要如何追 ?
總於可以體諒到 domain model 的強大,雖然這只是其它一個好處,但這個真的讓我記得很清楚,而且現在回想起來,我第二份工作時也就有用過,但那時也沒能體會,我在想可能是我大部份的情況都是在寫新的東西吧,到了現在當 teck leader 後,一直要處理 legacy,並且每天都被人問說這個的業務規則後,才能慢慢的體會……