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

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

using System;
using System.Collections.Generic;

//client code
var app = ShopApp.Loud();
app.UseCase.Place([("pen", 2), ("lamp", 1)]);
app.UseCase.Place([("chair", 1)]);

//a second root builds the same use case differently
var night = ShopApp.Quiet();
night.UseCase.Place([("lamp", 1)]);
Console.WriteLine($"saved {night.Orders.Count}");
//printed: order 1001 for 225
//         no chair
//         saved 1

//entity
record Order(int Id, int Total)
{
    public int Payable =>
        Total >= 200 ? Total - Total / 10 : Total;
}

//gateway
interface ICatalog
{
    int? Price(string sku);
}

//repository
interface IOrders
{
    void Save(Order order);

    int Count { get; }
}

//output port
interface IReport
{
    void Say(string text);
}

//use case: asks for its parts, never builds them
sealed class PlaceOrder(
    ICatalog catalog,
    IOrders orders,
    IReport report)
{
    private int nextId = 1001;

    public void Place(
        IReadOnlyList<(string Sku, int Count)> items)
    {
        int total = 0;
        foreach (var (sku, count) in items)
        {
            if (catalog.Price(sku) is not int price)
            {
                report.Say($"no {sku}");
                return;
            }
            total += price * count;
        }
        var order = new Order(nextId++, total);
        orders.Save(order);
        report.Say(
            $"order {order.Id} for {order.Payable}");
    }
}

//adapter
sealed class MemoryCatalog : ICatalog
{
    public int? Price(string sku) => sku switch
    {
        "pen" => 30,
        "lamp" => 190,
        _ => null,
    };
}

//adapter
sealed class MemoryOrders : IOrders
{
    private readonly List<Order> rows = [];

    public void Save(Order order) => rows.Add(order);

    public int Count => rows.Count;
}

//adapter
sealed class ConsoleReport : IReport
{
    public void Say(string text) =>
        Console.WriteLine(text);
}

//adapter
sealed class SilentReport : IReport
{
    public void Say(string text)
    {
    }
}

//composition root: the only place that sees every layer
//at once, and the only place where an adapter is built
sealed class ShopApp
{
    private ShopApp(
        ICatalog catalog,
        IOrders orders,
        IReport report)
    {
        Orders = orders;
        UseCase = new PlaceOrder(catalog, orders, report);
    }

    public PlaceOrder UseCase { get; }

    public IOrders Orders { get; }

    public static ShopApp Loud() => new(
        new MemoryCatalog(),
        new MemoryOrders(),
        new ConsoleReport());

    public static ShopApp Quiet() => new(
        new MemoryCatalog(),
        new MemoryOrders(),
        new SilentReport());
}


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

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

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

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

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

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