Сущность

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

# entity: the rules of the shop speak with an error of
# their own, never with a bare string
class ShopRule < StandardError; end

# entity
class OrderLine
    attr_reader :sku, :price, :count

    def initialize(sku, price, count)
        raise ShopRule, "bad count" if count <= 0
        @sku = sku
        @price = price
        @count = count
        # a line never changes after it is born
        freeze
    end

    def cost = price * count
end

# entity
class Order
    attr_reader :id

    def initialize(id)
        @id = id
        @lines = []
        @paid = false
    end

    def add(sku, price, count)
        raise ShopRule, "order is paid" if @paid
        @lines << OrderLine.new(sku, price, count)
        self
    end

    # the inner list is never handed out as it is
    def lines = @lines.dup.freeze

    def size = @lines.size

    def total = @lines.sum(&:cost)

    # business rule: 10% off from 200
    def discount = total >= 200 ? total / 10 : 0

    def payable = total - discount

    # business rule: an empty order is never paid
    def pay
        raise ShopRule, "empty order" if @lines.empty?
        @paid = true
        self
    end

    def paid? = @paid
end

# client code
# no database and no screen anywhere above
order = Order.new(1001)
order.add("pen", 30, 2)
order.add("lamp", 190, 1)
puts "order #{order.id}"
puts "lines #{order.size}"
puts "total #{order.total}"
puts "discount #{order.discount}"
puts "payable #{order.payable}"

order.pay
puts "paid #{order.paid?}"

# every refusal comes from the rules themselves:
# no storage and no screen took part in them
begin
    order.add("pen", 30, 1)
rescue ShopRule => error
    puts "refused: #{error.message}"
end

begin
    Order.new(1002).pay
rescue ShopRule => error
    puts "refused: #{error.message}"
end

begin
    OrderLine.new("pen", 30, 0)
rescue ShopRule => error
    puts "refused: #{error.message}"
end

# the list handed out is frozen, so nobody adds a line
# behind the back of the rules above
begin
    order.lines << OrderLine.new("chair", 1, 1)
rescue FrozenError
    puts "refused: lines are frozen"
end
# printed: order 1001
#          lines 2
#          total 250
#          discount 25
#          payable 225
#          paid true
#          refused: order is paid
#          refused: empty order
#          refused: bad count
#          refused: lines are frozen


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

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

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

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

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

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