Корень композиции

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

# entity
class Order
    attr_reader :id, :total

    def initialize(id, total)
        @id = id
        @total = total
    end

    def payable
        total >= 200 ? total - total / 10 : total
    end
end

# gateway
module Catalog
    def price(sku)
        raise NotImplementedError, "#{self.class}#price"
    end
end

# repository
module Orders
    def save(order)
        raise NotImplementedError, "#{self.class}#save"
    end

    def count
        raise NotImplementedError, "#{self.class}#count"
    end
end

# output port
module Report
    def say(text)
        raise NotImplementedError, "#{self.class}#say"
    end
end

# use case: asks for its parts, never builds them
class PlaceOrder
    def initialize(catalog, orders, report)
        @catalog = catalog
        @orders = orders
        @report = report
        @next_id = 1001
    end

    def place(items)
        total = 0
        items.each do |sku, count|
            price = @catalog.price(sku)
            unless price
                @report.say("no #{sku}")
                return
            end
            total += price * count
        end
        order = Order.new(@next_id, total)
        @next_id += 1
        @orders.save(order)
        text = "order #{order.id} for #{order.payable}"
        @report.say(text)
    end
end

# adapters
class MemoryCatalog
    include Catalog

    def price(sku) = {"pen" => 30, "lamp" => 190}[sku]
end

class MemoryOrders
    include Orders

    def initialize
        @rows = []
    end

    def save(order) = @rows << order

    def count = @rows.size
end

class ConsoleReport
    include Report

    def say(text) = puts(text)
end

class SilentReport
    include Report

    def say(text) = nil
end

# composition root: the only place that sees every layer
# at once, and the only place where an adapter is built.
# the two ready builds are class methods, because the
# root also hands out the parts it has put together
class ShopApp
    attr_reader :use_case, :orders

    def initialize(catalog, orders, report)
        @orders = orders
        @use_case = PlaceOrder.new(catalog, orders, report)
    end

    def self.loud
        new(MemoryCatalog.new, MemoryOrders.new,
            ConsoleReport.new)
    end

    def self.quiet
        new(MemoryCatalog.new, MemoryOrders.new,
            SilentReport.new)
    end
end

# client code
app = ShopApp.loud
app.use_case.place({"pen" => 2, "lamp" => 1})
app.use_case.place({"chair" => 1})
puts "saved #{app.orders.count}"

# a second root builds the same use case differently,
# and neither the scenario nor the entity knows it
night = ShopApp.quiet
night.use_case.place({"lamp" => 1})
night.use_case.place({"pen" => 2})
puts "saved at night #{night.orders.count}"

# no adapter is built outside the root: the client asks
# for a ready application and gets one, and the scenario
# it got is the same class in both builds
puts "use case #{app.use_case.class}"
same = app.use_case.class == night.use_case.class
puts "same class #{same}"
# printed: order 1001 for 225
#          no chair
#          saved 1
#          saved at night 2
#          use case PlaceOrder
#          same class true


Корень композиции - единственное место, где слои соединяются и строятся адаптеры; остальное приложение только просит свои части.
Read more

Задача. Сценарию нужны каталог цен, хранилище заказов и способ выдать отчёт. Если сценарий сам создаёт эти части, он называет адаптеры внешнего слоя и зависит от них. Тест не может подставить ему другие части. А если каждая часть программы сама собирает свои части, никто не видит, как соединено приложение.

Идея. Корень композиции - единственное место, где соединяются слои и создаются адаптеры. Адаптер - это внешний код, который реализует порт, то есть интерфейс, объявленный внутренним слоем. Сценарий только просит свои части через порты и никогда не создаёт их сам. Корень знает все слои сразу, и в этом его работа. Весь остальной код соблюдает правило зависимостей: внутренний код никогда не знает внешнего. Ни фреймворк, ни контейнер для этого не нужны: корень - обычный код в начале программы.

Как читать пример. Сценарий просит три порта: шлюз для цен, репозиторий для заказов и выходной порт для отчётов. Во внешнем слое для них есть адаптеры: каталог и хранилище в памяти, отчёт, который печатает, и отчёт, который молчит. Пример собирает приложение дважды. В первой сборке отчёт печатает: клиентский код оформляет заказ 1001 с суммой к оплате 225 и заказ на неизвестный товар. Во второй сборке отчёт молчит, поэтому её заказы ничего не печатают. Сохранены ли они, видно только по числу заказов в хранилище. Сценарий в обеих сборках один и тот же.

Когда применять. У каждого приложения один корень композиции, в самом начале; у теста может быть свой маленький корень с поддельными адаптерами. Короткому скрипту без портов корень не нужен.

Частая ошибка. Создать адаптер внутри сценария, пусть даже один раз. Тогда сценарий зависит от внешнего слоя, а корень больше не показывает, как соединено приложение.