Сущность

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

//entity
class OrderLine(
    val sku: String,
    val price: Int,
    val count: Int
) {
    init {
        //an invariant guarded where the data is born
        require(count > 0) { "bad count" }
    }

    val cost: Int
        get() = price * count
}

//entity
class Order(val id: Int) {
    private val lines = mutableListOf<OrderLine>()

    //only the entity itself may flip the flag
    var paid: Boolean = false
        private set

    val size: Int
        get() = lines.size

    val total: Int
        get() = lines.sumOf { it.cost }

    //business rule: 10% off from 200
    val discount: Int
        get() = if (total >= 200) total / 10 else 0

    val payable: Int
        get() = total - discount

    //business rule: a paid order is closed for changes
    fun add(sku: String, price: Int, count: Int) {
        check(!paid) { "order already paid" }
        lines.add(OrderLine(sku, price, count))
    }

    //business rule: an empty order is never paid
    fun pay() {
        check(lines.isNotEmpty()) { "empty order" }
        paid = true
    }
}

//client code
//no database and no screen anywhere above
val order = Order(1001)
order.add("pen", 30, 2)
order.add("lamp", 190, 1)
println("order ${order.id}")
println("lines ${order.size}")
println("total ${order.total}")
println("discount ${order.discount}")
println("payable ${order.payable}")
order.pay()
println("paid ${order.paid}")

//the entity guards its own state, not a screen
try {
    order.add("pen", 30, 1)
} catch (error: IllegalStateException) {
    println("refused: ${error.message}")
}

//a smaller order stays under the discount rule
val small = Order(1002)
small.add("pen", 30, 1)
println("order ${small.id}")
println("total ${small.total}")
println("discount ${small.discount}")

//the rules refuse bad data, and nobody else has to
try {
    small.add("chair", 30, 0)
} catch (error: IllegalArgumentException) {
    println("refused: ${error.message}")
}

try {
    Order(1003).pay()
} catch (error: IllegalStateException) {
    println("refused: ${error.message}")
}
//printed: order 1001
//         lines 2
//         total 250
//         discount 25
//         payable 225
//         paid true
//         refused: order already paid
//         order 1002
//         total 30
//         discount 0
//         refused: bad count
//         refused: empty order


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

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

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

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

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

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