实体

设计模式 / 整洁架构模式
image

//entity
class OrderLine {
    constructor(
        readonly sku: string,
        readonly price: number,
        readonly count: number) {
        if (count <= 0) {
            throw new Error("bad count " + sku)
        }
    }

    cost(): number {
        return this.price * this.count
    }
}

//entity
class Order {
    private lines: OrderLine[] = []
    private isPaid = false

    constructor(readonly id: number) {
    }

    add(sku: string, price: number, count: number) {
        this.lines.push(new OrderLine(sku, price, count))
    }

    //the goods leave as a copy: readonly is a word for
    //the compiler and guards nothing while running
    goods(): ReadonlyArray<OrderLine> {
        return this.lines.slice()
    }

    size(): number {
        return this.lines.length
    }

    total(): number {
        let sum = 0
        for (let line of this.lines) {
            sum += line.cost()
        }
        return sum
    }

    //business rule: 10% off from 200
    discount(): number {
        let total = this.total()
        return total >= 200 ? Math.trunc(total / 10) : 0
    }

    payable(): number {
        return this.total() - this.discount()
    }

    //business rule: an empty order is never paid
    pay() {
        if (this.lines.length === 0) {
            throw new Error("empty order")
        }
        this.isPaid = true
    }

    paid(): boolean {
        return this.isPaid
    }
}

//client code
//no database and no screen anywhere above
let order = new Order(1001)
order.add("pen", 30, 2)
order.add("lamp", 190, 1)
console.log("order " + order.id)
console.log("lines " + order.size())
console.log("total " + order.total())
console.log("discount " + order.discount())
console.log("payable " + order.payable())
order.pay()
console.log("paid " + order.paid())

//the same rule on a smaller order
let small = new Order(1002)
small.add("pen", 30, 2)
console.log("small total " + small.total())
console.log("small discount " + small.discount())

//a cast drops readonly, but the copy keeps the order
//whole: the rule lives inside, not in the type
let taken = order.goods() as OrderLine[]
taken.pop()
console.log("still " + order.size())

try {
    new Order(1003).pay()
} catch (error) {
    console.log("refused: " + (error as Error).message)
}

try {
    order.add("pen", 30, 0)
} catch (error) {
    console.log("refused: " + (error as Error).message)
}
//printed: order 1001
//         lines 2
//         total 250
//         discount 25
//         payable 225
//         paid true
//         small total 60
//         small discount 0
//         still 2
//         refused: empty order
//         refused: bad count pen


An entity holds a business rule of the shop and knows nothing about a database, a screen or the network, so it can be tested without them.
Read more

问题。商店对金额达到 200 的每笔订单减免 10%。如果这条规则写在网页里,又在数据库查询里写了一遍,两份副本很快就会不一致。要检查这条规则,就得启动网页或数据库。商店最重要的知识分散各处,难以测试。

思路。实体是一段数据,连同与它相关的业务规则:订单包含什么,总额如何计算,何时可以付款。它对数据库、界面或网络一无所知。整洁架构有一条主要规则,即依赖规则:外层代码可以了解内层代码,但内层代码从不了解外层代码。实体是最内层的代码,因此不依赖任何东西。

如何阅读示例。示例中有两个实体:订单行和订单本身。订单汇总各行,对 200 及以上的金额减免 10%,并计算应付金额。订单为空时,它拒绝付款。客户端代码构建含两个订单行的订单 1001,并打印其中的数字:总额 250,折扣 25,应付 225。它还尝试为一个空订单付款,实体予以拒绝。在某些语言中,实体是带有函数的普通值,而不是类;它的角色不变。

何时使用。业务中每个拥有自己规则的概念,例如订单或账户,都应做成一个实体。没有规则的数据不需要实体:简单的结构就够了。

常见错误。实体自己把自己保存到数据库,或者为界面格式化自己。这样它就依赖于外层代码,它的规则也无法再单独测试。