Сущность

Паттерны проектирования / Паттерны чистой архитектуры
image

//entity: the rules say no with their own error type
enum OrderRule: Error {
    case badCount
    case emptyOrder
    case orderPaid

    var reason: String {
        switch self {
        case .badCount: return "bad count"
        case .emptyOrder: return "empty order"
        case .orderPaid: return "order already paid"
        }
    }
}

//entity
struct OrderLine {
    let sku: String
    let price: Int
    let count: Int

    //an invariant guarded where the data is born
    init(sku: String, price: Int, count: Int) throws {
        guard count > 0 else { throw OrderRule.badCount }
        self.sku = sku
        self.price = price
        self.count = count
    }

    var cost: Int { price * count }
}

//entity
//a struct, not a class: no screen holding the order
//can change yesterday's order behind the rules' back
struct Order {
    let id: Int
    private(set) var lines: [OrderLine] = []
    private(set) var paid = false

    init(id: Int) {
        self.id = id
    }

    var size: Int { lines.count }

    var total: Int {
        lines.reduce(0) { sum, line in sum + line.cost }
    }

    //business rule: 10% off from 200
    var discount: Int {
        total >= 200 ? total / 10 : 0
    }

    var payable: Int { total - discount }

    //business rule: a paid order is closed for changes
    mutating func add(_ sku: String, _ price: Int,
                      _ count: Int) throws {
        guard !paid else { throw OrderRule.orderPaid }
        lines.append(try OrderLine(sku: sku, price: price,
                                   count: count))
    }

    //business rule: an empty order is never paid
    mutating func pay() throws {
        guard !lines.isEmpty else {
            throw OrderRule.emptyOrder
        }
        paid = true
    }
}

func refuse(_ work: () throws -> Void) {
    do {
        try work()
    } catch let rule as OrderRule {
        print("refused: \(rule.reason)")
    } catch {
        print("refused: \(error)")
    }
}

//client code
//no database and no screen anywhere above
var order = Order(id: 1001)
try order.add("pen", 30, 2)
try order.add("lamp", 190, 1)
print("order \(order.id)")
print("lines \(order.size)")
print("total \(order.total)")
print("discount \(order.discount)")
print("payable \(order.payable)")
try order.pay()
print("paid \(order.paid)")

//a smaller order stays under the discount rule
var draft = Order(id: 1002)
try draft.add("pen", 30, 1)
print("order \(draft.id)")
print("total \(draft.total)")
print("discount \(draft.discount)")

//the rules refuse bad data, and nobody else has to
refuse { try order.add("pen", 30, 1) }
refuse { try draft.add("chair", 30, 0) }
refuse { var empty = Order(id: 1003); try empty.pay() }
//printed: order 1001
//         lines 2
//         total 250
//         discount 25
//         payable 225
//         paid true
//         order 1002
//         total 30
//         discount 0
//         refused: order already paid
//         refused: bad count
//         refused: empty order


Сущность держит правило предметной области и ничего не знает ни о базе, ни об экране, ни о сети, поэтому её можно проверить без них.
Read more

Задача. Магазин даёт скидку 10% на любой заказ от 200. Если это правило живёт на веб-странице и ещё раз в запросе к базе данных, две копии скоро разойдутся. Чтобы проверить правило, приходится запускать страницу или базу. Самое важное знание магазина разбросано, и проверить его трудно.

Идея. Сущность - это данные вместе с бизнес-правилами, которые к ним относятся: из чего состоит заказ, как считается его итог, когда его можно оплатить. О базе данных, экране или сети она ничего не знает. В чистой архитектуре есть одно главное правило - правило зависимостей: внешний код может знать внутренний, но внутренний никогда не знает внешнего. Сущности - самый внутренний код, поэтому они ни от чего не зависят.

Как читать пример. Сущностей две: позиция заказа и сам заказ. Заказ складывает свои позиции, даёт скидку 10% при сумме от 200 и считает сумму к оплате. Пока заказ пуст, он отказывается принимать оплату. Клиентский код собирает заказ 1001 из двух позиций и печатает его числа: итог 250, скидка 25, к оплате 225. Ещё он пытается оплатить пустой заказ, и сущность отказывает. В некоторых языках сущность - не класс, а обычное значение с функциями; роль у неё та же.

Когда применять. Сущность стоит заводить для каждого понятия бизнеса, у которого есть свои правила, например для заказа или счёта. Данным без правил сущность не нужна: хватит простой структуры.

Частая ошибка. Сущность, которая сама сохраняет себя в базу данных или оформляет себя для экрана. Тогда она зависит от внешнего кода, и её правила уже нельзя проверить отдельно.