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

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

//a port method with no body names the class that left
//it out: JavaScript has no interfaces, so a port here
//is a base class and extends says who serves it
function notWritten(owner, method) {
    throw new Error(owner.constructor.name + "." + method);
}

//entity
class Order {
    #total;

    constructor(id, total) {
        this.id = id;
        this.#total = total;
    }

    payable() {
        let cut = this.#total >= 200 ? this.#total / 10 : 0;
        return this.#total - Math.trunc(cut);
    }
}

//gateway
class Catalog {
    price(sku) {
        notWritten(this, "price");
    }
}

//repository
class Orders {
    save(order) {
        notWritten(this, "save");
    }

    count() {
        notWritten(this, "count");
    }
}

//output port
class Report {
    say(text) {
        notWritten(this, "say");
    }
}

//use case: asks for its parts, never builds them
class PlaceOrder {
    #catalog;
    #orders;
    #report;
    #nextId = 1001;

    constructor(catalog, orders, report) {
        this.#catalog = catalog;
        this.#orders = orders;
        this.#report = report;
    }

    place(items) {
        let total = 0;
        for (let [sku, count] of items) {
            let price = this.#catalog.price(sku);
            if (price === undefined) {
                this.#report.say("no " + sku);
                return;
            }
            total += price * count;
        }
        let order = new Order(this.#nextId++, total);
        this.#orders.save(order);
        this.#report.say("order " + order.id +
            " for " + order.payable());
    }
}

//adapters
class MemoryCatalog extends Catalog {
    #prices = new Map([["pen", 30], ["lamp", 190]]);

    price(sku) {
        return this.#prices.get(sku);
    }
}

class MemoryOrders extends Orders {
    #rows = new Map();

    save(order) {
        this.#rows.set(order.id, order);
    }

    count() {
        return this.#rows.size;
    }
}

class ConsoleReport extends Report {
    say(text) {
        console.log(text);
    }
}

class SilentReport extends Report {
    say(text) {
    }
}

//composition root: the only place that sees every layer
//at once, and the only place where new stands next to an
//adapter. No container is needed; a function is the root
function build(report) {
    let orders = new MemoryOrders();
    return {
        useCase: new PlaceOrder(
            new MemoryCatalog(), orders, report),
        orders: orders
    };
}

function loud() {
    return build(new ConsoleReport());
}

function quiet() {
    return build(new SilentReport());
}

//client code
let app = loud();
app.useCase.place(new Map([["pen", 2], ["lamp", 1]]));
app.useCase.place(new Map([["chair", 1]]));

//a second root builds the same use case differently, and
//neither of them ever named a class inside the use case
let night = quiet();
night.useCase.place(new Map([["lamp", 1]]));
console.log("night saved " + night.orders.count());
console.log("loud saved " + app.orders.count());
//printed: order 1001 for 225
//         no chair
//         night saved 1
//         loud saved 1


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

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

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

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

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

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