Skip to content

Latest commit

 

History

History
860 lines (643 loc) · 60.5 KB

13-module-integration.md

File metadata and controls

860 lines (643 loc) · 60.5 KB

Интеграция модулей

Сложные приложения состоят из множества частей. Взаимодействие частей между собой влияет на организацию и сложность кода. Чем взаимодействие проще и очевиднее, тем легче читать и изменить код приложения. В этой главе мы рассмотрим, как замечать чрезмерно запутанную организацию кода и как упрощать компоновку приложения.

Зацепление и связность

Плохой код заметен по страху его менять. Страх возникает, когда нам кажется, что после изменений «всё развалится» или «придётся обновлять кучу другого кода». Такие ощущения возникают, если модули приложения слишком много знают друг о друге.

К слову 🧩
Под модулем мы будем понимать обособленную часть приложения, которая отвечает за конкретную задачу и общается с внешним миром или другими модулями через публичное API.

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

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

Разделение ответственности

В хорошо организованном приложении работа над задачей вызывает изменения только в коде, который связан с этой задачей. Этот принцип известен, как разделение ответственности (Separation of Concerns, SoC).2

Этот принцип помогает ограничивать распространение изменений по кодовой базе. Если весь код, ответственный за одну задачу, находится в одном модуле, то и изменения этой задачи будут влиять только на этот модуль. Всё, что к задаче не относится, находится за пределами модуля и не повлияет на его код.

Степень «соответствия» кода задаче называется связностью (cohesion).3 Чем выше связность модуля, тем ближе его код по смыслу к задаче, ради которой его написали и тем проще отыскать его в кодовой базе.

Правило интеграции модулей

Первое и главное, что нам стоит проверить при анализе взаимодействия модулей во время рефакторинга, это правило:


❗️ Зацепление должно быть низким, а связность — высокой


Скомпонованная по такому правилу программа выглядит как «островки задач», связанные «мостиками» публичного API, событий или сообщений:

Несколько модулей-островов, соединённых между собой тонкими мостами; каждый модуль содержит составные части, которые соединены друг с другом сильнее, чем острова между собой

«Островки» отвечают за конкретные задачи предметной области и общаются друг с другом по «мостикам» публичного API, событий или сообщений

Разделение задач

Чтобы понять, по каким признакам во время рефакторинга искать слабое разделение задач, рассмотрим пример. Модуль purchase из фрагмента ниже сильно зацеплен с модулем cart. Он использует внутренние детали объекта корзины (структуру объекта и тип поля products), чтобы узнать, пустая ли она:

// purchase.ts

async function makePurchase(user, cart) {
  if (!cart.products.length) throw new Error("Cart is empty!");

  const order = createOrder(user, cart);
  await sendOrder(order);
}

Проблема этого кода в нарушении инкапсуляции. Модуль purchase не знает и не должен знать, как правильно проверить, пуста ли корзина.

Детали проверки корзины, не входят в задачу оформления заказа. Нам важен факт, что корзина непустая, но не важно, как этот факт будет определён. Реализация проверки — это задача модуля корзины, потому что именно он создаёт этот объект и знает, как держать его валидным:

// cart.ts
// Выносим проверку пустоты корзины в модуль `cart`:
export function isEmpty(cart) {
  return !cart.products.length;
}

// purchase.ts
import { createOrder } from "./order";
import { isEmpty } from "./cart";

async function makePurchase(user, cart) {
  if (isEmpty(cart)) throw new Error("Cart is empty!");

  const order = createOrder(user, cart);
  await sendOrder(order);
}

Теперь, если внутренняя структура корзины по каким-то причинам изменится, изменения ограничатся модулем cart:

// cart.ts
type Cart = {
  // Было products, стало items:
  items: ProductList;
};

export function isEmpty(cart) {
  // Место, которое надо поправить:
  return !cart.items.length;
}

// purchase.ts
async function makePurchase(user, cart) {
  if (isEmpty(cart)) throw new Error("Cart is empty!");
  // Использование в других модулях осталось неизменным,
  // мы ограничили распространение изменений.
}

...Модули, которые используют функцию isEmpty из публичного API, останутся неизменными. Если бы модули использовали устройство корзины напрямую, то при изменении свойства, пришлось бы обновлять их все.

Как определить связность

Часто с первого взгляда непонятно, относится задача к конкретному модулю, или нет. Для проверки гипотез об этом мы можем обращать внимание на данные, с которыми работает модуль или функция.

Данные — это входные и выходные параметры, а также зависимости и контекст, которые использует модуль. Чем меньше данные одного модуля схожи с данными другого модуля, тем выше вероятность, что они относятся к разным задачам. Если, например, функция часто работает с данными из соседнего модуля, скорее всего, она должна быть его частью.

К слову 🦨
Мы можем знать эту проблему как запах кода “Feature Envy”.4

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

// budget.js

// Создаёт новый бюджет:
function createBudget(amount, days) {
  const daily = Math.floor(amount / days);
  return { amount, days, daily };
}

// Считает, сколько потрачено всего:
function totalSpent(history) {
  return history.reduce((tally, record) => tally + record.amount, 0);
}

// Добавляет трату, уменьшая доступное количество денег
// и создавая новую запись в истории трат:
function addSpending(record, { budget, history }) {
  const newBudget = { ...budget, amount: budget.amount - record.amount };
  const newHistory = [...history, record];

  return {
    budget: newBudget,
    history: newHistory,
  };
}

Задача модуля budget — отвечать за преобразования бюджета. Однако мы видим функции, которые работают не только с ним:

  • Функция totalSpent работает только с историей записей о расходах;
  • Функция addSpending работает с бюджетом, но тоже использует данные истории расходов.

Из данных, с которыми работают эти функции, мы можем сделать вывод, что они не так уж и относятся к бюджету. Например, totalSpent — больше относится к истории расходов, а addSpending — больше похожа на целый пользовательский сценарий приложения.

Попробуем распилить код по фичам, выделив историю и юзкейс в отдельные модули:

// budget.js
// Тут держим только код,
// отвечающий за преобразования бюджета:

function createBudget(amount, days) {
  const daily = Math.floor(amount / days);
  return { amount, days, daily };
}

function decreaseBy(budget, record) {
  const updated = budget.amount - record.amount;
  return { ...budget, amount: updated };
}

// history.js
// Здесь только код, отвечающий
// за преобразования истории трат:

function totalSpent(history) {
  return history.reduce((tally, record) => tally + record.amount, 0);
}

function appendRecord(history, record) {
  return [...history, record];
}

// addSpending.js
// Здесь описываем юзкейс добавления траты:
// - уменьшаем бюджет;
// - добавляем запись в историю.

function addSpending(spending, appState) {
  const budget = decreaseBy(state.budget, spending);
  const history = appendRecord(state.history, spending);
  return { budget, history };
}

В начале жизни приложения подобное строгое разделение модулей и фич может быть не нужно. Но если приложение надо масштабировать и добавлять больше пользовательских сценариев, вероятно, потребуется совмещать функциональность разных модулей. Нечёткое разделение модулей может привести к неявным зависимостями между ними, из-за которых компоновать функциональность будет сложнее.

Для беспроблемного масштабирования нам стоит следить за зацеплением и связностью. Если мы знаем, что будем расширять и переиспользовать функциональность, то лучше разделить код по разным модулям так, чтобы между ними было минимум скрытых зависимостей.

Контракты

Публичное API модуля можно назвать простой формой контракта.56 Контракты фиксируют гарантии одной сущности перед другими: они требуют определённых аргументов и обязуют функции возвращать конкретный результат. Это позволяет другим частям программы полагаться не на устройство модуля, а только на его «обещания» и строить свою работу исходя из них.

Рассмотрим на примере, зачем это нужно. В коде ниже мы опираемся на устройство модуля api, тем самым повышая зацепление:

// ...
await api.post(api.baseUrl + "/" + api.createUserUrl, { body: user });
// ...
await api.post(api.baseUrl + "/posts/" + api.posts.create, post);

Модуль api не даёт чётких обещаний, как он будет работать, поэтому при его использовании нам надо знать, как он устроен. Но прямая зависимость от «внутренностей» повышает зацепление: если мы изменим модуль api, то придётся менять и код, который его использует. Это тормозит развитие приложения.

Вместо этого модуль api мог бы объявить контракт — набор гарантий, описывающих как он будет работать:

type ApiResponse = {
  state: "OK" | "ERROR";
};

interface ApiClient {
  createUser(user: User): Promise<ApiResponse>;
  createPost(post: Post): Promise<ApiResponse>;
}

Затем мы бы реализовали этот контракт внутри модуля api, не выпуская наружу лишних деталей:

const client: ApiClient = {
  createUser: (user) =>
    api.post(api.baseUrl + "/" + api.createUserUrl, { body: user }),

  createPost: (post) =>
    api.post(api.baseUrl + "/posts/" + api.posts.create, post),
};

...А после использовали бы его, уже опираясь только на контракт:

// ...
await client.createUser(user);
// ...
await client.createPost(post);
Уточнение 📑
В формальном определении контракты — это пред- и постусловия в виде прописанных проверяемых спецификаций.5 На практике я почти не встречал контрактов в таком виде, зато в виде фиксирования гарантий — повсеместно.
Гарантии — это не обязательно сигнатура или интерфейс. Это могут быть устные или письменные договорённости, DTO, формат сообщений и т.д. Главное, чтобы эти договорённости фиксировали поведение частей системы друг перед другом.

Одни и те же обещания могут выполнять разные модули, поэтому если опираться на обещания, реализацию становится проще подменять, например, во время тестирования:

// Описываем «контракт» работы хранилища.
// В интерфейсе указываем, какой метод можно использовать,
// что он принимает в качестве аргумента
// и что он возвращает как результат:

interface SyncStorage {
  save(value: string): void;
}

// В аргументе `saveToStorage` указываем не конкретную сущность,
// а «что-то, что реализует интерфейс `SyncStorage`»:

function saveToStorage(value: string, storage: SyncStorage) {
  if (value) storage.save(value);
}

// ...

describe("when given a non-empty value", () => {
  // В тестах описываем мок хранилища,
  // как «что-то, что реализует `SyncStorage`»:
  const mock: SyncStorage = { save: jest.fn() };

  it("should save it into the given storage", () => {
    // Тогда во время тестов сможем «подменить»
    // реализацию хранилища на мок:
    saveToStorage("Hello World!", mock);
    expect(mock.save).toHaveBeenCalled();
  });
});

...Или даже во время рантайма для замены одного алгоритма или куска приложения другим:

// Сохраняем настройки в куки или локальное хранилище
// в зависимости от пользовательских настроек:

const storage = preferences.useCookie ? cookieAdapter : localStorageAdapter;
const saveCurrentTheme = () => saveToStorage(THEME, storage);

// Пока `cookieAdapter` и `localStorageAdapter` оба реализуют `SyncStorage`,
// мы можем использовать любой из них в функции `saveToStorage`.
К слову 🔁
Идея такой «подмены» модулей лежит в основе шаблона «Стратегия» и инъекции зависимостей.789

События и сообщения

Чем ниже зацепление, тем больше общение модулей становится похоже на обмен сообщениями. Общение между сервером и клиентом по REST — это пример такого общения.10 Клиент и сервер ничего не знают об устройстве друг друга и общаются только по заранее описанному контракту — сообщениями определённого вида с данными внутри.

Сообщения можно передавать как напрямую от одного модуля к другому через публичное API, так и через специальную сущность — передатчик. Во втором случае модули вообще ничего не будут знать друг о друге и будут зацеплены только через передатчика сообщений или событий:

В центре главная большая стрелка, идущая слева направо; к ней присоединяются различные прямоугольники-модули, находящиеся вокруг

Общение сводится к передаче и получению сообщений от передатчика

Уточнение 📧
Я намеренно не называл передатчик «шиной событий», «очередью» или «брокером» сообщений. Между ними есть разница,111213 но для сути этой главы она не критична, поэтому я не стал заострять внимание на конкретном термине.
К слову 📆
Общение через события можно называть «идеальным общением» между модулями, потому что оно связывает модули только протоколами сообщений и их отправки. Но настройка такого общения часто ресурсозатратна, а для маленьких проектов и вовсе может оказаться оверхедом.

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

В качестве примитивного передатчика можно представить паттерн «Наблюдатель».14 В нём модули подписываются на сообщения определённых видов и реагируют на них, когда они приходят:

// Наблюдатель — функция, которая будет реагировать
// на сообщения `Message`:

type Observer = (message: Message) => void;

// Передатчик даёт возможность подписаться на события
// и может уведомить всех подписчиков о новом сообщении:

type Observable = {
  subscribe: (listener: Observer) => void;
  notifyAll: (message: Message) => void;
};

// Реализация передатчика в нашем случае — это объект с двумя методами
// и список подписчиков в виде массива `listeners`:

const listeners = [];
const bus: Observable = {
  subscribe: (listener) => listeners.push(listener),
  notifyAll: (message) => listeners.forEach((listener) => listener(message)),
};

// Подписчик будет знать, что ему на вход передадут сообщение `Message` — это контракт.
// По типу сообщения он сможет понять, нужно ли ему обработать это сообщение:

const onUserUpdated = ({ type, payload }) => {
  if (type === "updateUser") {
    // Отреагировать на сообщение,
    // если тип подходит.

    const [firstName, lastName] = payload;
    // ...
  }
};

// Подписать функцию `onUserUpdated` на сообщения
// можно с помощью метода `subscribe`:

bus.subscribe(onUserUpdated);

// При появлении нового сообщения передатчик уведомит о нём
// всех подписавшихся с помощью метода `notifyAll`:

bus.notifyAll({
  type: "updateUser",
  payload: ["Alex", "Bespoyasov"],
});
Уточнение 🌊
В продакшене писать свою реализацию «Наблюдателя», вероятно, не потребуется. Для работы с этим паттерном уже существуют решения, например, RxJS.15

Полностью расцепленное общение нужно не всегда, но может быть полезно, когда на одинаковые сообщения должны реагировать разные части системы, но нам не хочется повышать зацепление между ними.

Зависимости

Говоря о зацеплении и интеграции модулей, стоит упомянуть управление зависимостями. Под зависимостями для простоты будем иметь в виду любой код, который используется нашим кодом. Например, в функции randomInt кроме двух аргументов мы используем метод Math.random — это зависимость:

function randomInt(min, max) {
  return Math.random() * (max - min) + min;
}
К слову 👻
Зависимость от Math в примере выше неявная, потому что Math используется напрямую в теле функции и не обозначен, как аргумент. Такие неявные зависимости усиливают зацепление. Если попробовать протестировать функцию randomInt, нам придётся делать глобальный мок объекта Math.

Управлять зависимостями можно по-разному, это зависит (no pun intended) от парадигмы и стиля кода. Однако обычно удобно отделять зависимости, производящие эффекты, от остальных. Такое разделение помогает приводить код к Impureim-структуре, о пользе которой мы говорили в главе о сайд-эффектах. Далее мы посмотрим на примеры такого рефакторинга в коде, написанном в разных парадигмах.

Объектная композиция

В объектно-ориентированном программировании юнит композиции — это объект. Объекты могут смешивать данные и действия (состояние и методы), и поэтому компоновать объекты обычно труднее, чем функции. В частности большая часть паттернов проектирования и принципы SOLID решают именно проблемы объектной композиции.16

К слову 👀
Стоит отметить, что объектный код можно писать избегая этих проблем, если разделять данные и действия. Просто в функциональном программировании к этому подталкивает сама парадигма, а в ООП приходится прикладывать усилия, чтобы об этом помнить.

Для примера посмотрим на код приложения для управления финансами:

class BudgetManager {
  constructor(private settings: BudgetSettings, private budget: Budget) {}

  // Главная проблема кода в нарушении CQS: здесь эффекты смешаны с логикой.
  // Этот класс одновременно валидирует данные и обновляет значение бюджета...
  checkIncome(record: Record): MoneyAmount | boolean {
    if (record.createdAt > this.budget.endsAt) return false;

    const saving = record.amount * this.settings.piggyBankFraction;
    this.budget.topUp(record.amount - saving);

    return saving;
  }
}

// ...Но этого не видно на верхнем уровне композиции.
// Мы не сможем определить, что есть какой-то эффект,
// пока не посмотрим внутрь кода `BudgetManager`.
class AddIncomeCommandHandler {
  constructor(private manager: BudgetManager, private piggyBank: PiggyBank) {}

  execute({ record }: AddSpendingCommand) {
    const saving = this.manager.checkIncome(record);

    if (!saving) return false;
    this.piggyBank.add(saving);
  }
}
К слову 💉
В примере я подразумеваю, что мы используем внедрение зависимостей (Dependency Injection, DI) через конструкторы классов.8 Я не буду останавливаться на нём отдельно, но оставлю несколько ссылок, где об этом написано подробнее.179

В примере выше из-за нарушения CQS нам не ясно, сколько эффектов на самом деле происходит при вызове метода execute. Мы увидели 2, но нет гарантий, что this.budget.topUp не меняет что-нибудь кроме объекта budget.

Компоновка сайд-эффектов сводит на нет суть абстракции: чем больше эффектов, тем больше общего состояния, за которым надо следить и держать в голове — с этим трудно работать. Если компоновки сайд-эффектов можно избежать, то лучше её избежать.

Вместо этого можно выделить преобразования данных, а эффекты отодвинуть к краям приложения. Так получилось бы разделить эффекты и логику:

// Валидацию вынесем в отдельную сущность.
// Этот класс будет заниматься только валидацией.
class AddIncomeValidator {
  constructor(private budget: Budget) {}

  // При необходимости, метод `canAddIncome` можно сделать полностью чистым,
  // если передавать значение `endsAt`, как аргумент.
  canAddIncome(record: Record) {
    return record.createdAt <= this.budget.endsAt;
  }
}

// На верхнем уровне разделим логику и эффекты.
// Получится Impureim-бутерброд, о котором мы говорили раньше:
// - Эффекты для получения данных (например, работа с БД на бекенде);
// - Логика обработки данных (доменные функции, создание сущностей);
// - Эффекты для сохранения данных (или вывода на экран).
class AddIncomeCommandHandler {
  constructor(
    // Эта же техника позволит «вытащить» наружу проблемы с зацеплением.
    // Если в классе набирается слишком много зависимостей,
    // то, вероятно, стоит подумать об улучшении его дизайна.
    private settings: BudgetSettings,
    private validator: AddIncomeValidator,
    private budget: Budget,
    private piggyBank: PiggyBank
  ) {}

  execute({ record }: AddSpendingCommand) {
    // Валидация:
    if (!this.validator.validate(record)) return false;

    // Pure(-ish because of injected settings) logic,
    // can be extracted into a separate module:

    // Чистая (почти — из-за внедрённых `this.settings`) логика.
    // Её можно вынести в отдельный метод или модуль,
    // я решил оставить её здесь для наглядности «слоёв бутерброда»:
    const saving = record.amount * this.settings.piggyBankFraction;
    const income = record.amount - saving;

    // Эффекты для сохранения данных:
    this.budget.topUp(income);
    this.piggyBank.add(saving);
  }
}

Разделение данных и поведения

Следующим шагом было бы хорошо отделить данные от поведения. Объекты бюджета и копилки тогда превратились бы в «контейнеры данных» — сущности (entities в терминах DDD),1819 а сохранением данных занимались бы «сервисы»:

class AddIncomeCommandHandler {
  constructor(
    private settings: BudgetSettings,
    private validator: AddIncomeValidator,
    private budgetRepository: BudgetUpdater,
    private piggyBankRepository: PiggyBankUpdater
  ) {}

  // Объекты бюджета и копилки теперь не содержат поведения, только данные:
  execute({ record, budget, piggyBank }: AddSpendingCommand) {
    if (!this.validator.validate(record, budget)) return false;

    // Преобразования данных, бизнес-логика:
    const saving = record.amount * this.settings.piggyBankFraction;
    const income = record.amount - saving;

    // Обновлённые объекты с данными:
    const newBudget = new Budget({ ...budget, income });
    const newPiggyBank = new PiggyBank({ ...piggyBank, saving });

    // «Сервисы» с эффектами сохранения данных:
    this.budgetRepository.update(newBudget);
    this.piggyBankRepository.update(newPiggyBank);
  }
}

Разделение данных и поведения помогает отделять код, который меняется быстро (поведение), от кода, который меняется медленно (данные). Так изменения затрагивают меньше файлов, и это ограничивает их распространение по кодовой базе.20

Принцип разделения интерфейса

При описании поведения мы можем использовать CQS,21 как «интеграционный линтер». Например, если мы описываем сервис с набором команд, разница сигнатур может указать на нарушение CQS:

interface BudgetUpdater {
  updateBalance(balance: MoneyAmount): void;
  recalculateDuration(date: TimeStamp): void;

  // Упс! Метод что-то возвращает,
  // значит это запрос, а не команда:
  currentBalance(): MoneyAmount;
}

Тогда мы можем применить принцип разделения интерфейса (Interface Segregation Principle, ISP)16 и декомпозировать задачу:

interface BudgetSource {
  currentBalance(): MoneyAmount;
}

interface BudgetUpdater {
  updateBalance(balance: MoneyAmount): void;
  recalculateDuration(date: TimeStamp): void;
}
К слову 👓
Один из паттернов применения ISP — разделение сервисов чтения и записи данных. Разделять ли сервисы на уровне классов — зависит от задачи, но разделение на уровне интерфейсов помогает как минимум выразить разницу намерений.

Функциональная композиция

В проектах c «более функциональным кодом» тоже можно встретить «внедрение зависимостей», сделанное с помощью частичного применения функций. Польза такого внедрения в том, что оно делает неявные зависимости явными.

Например, функция listingQuery выводит список md-файлов из указанной папки:

const listingQuery = (query) => {
  return fs
    .readdirSync(query)
    .filter((fileName) => fileName.endsWith(".md"))
    .map((fileName) => fileName.replace(".md", ""));
};

const projectList = listingQuery("projects");

Она неявно зависит от модуля fs, который предоставляет доступ к файловой системе. В целом, это не страшно, но такую функцию неудобно тестировать — для её тестов потребуется глобальный мок для fs.

С частичным применением можно создать «функцию-фабрику». Она будет принимать «зависимости», как аргумент, и возвращать функцию listingQuery, как результат:

/**
 * 1. Внедрение «сервиса» system;
 * 2. Передача собственно аргументов;
 * 3. Использование «сервиса» system
 *    для получения нужных эффектов.
 */
const createListingQuery =
  ({ system }) =>
  (query) =>
    system
      .readdirSync(query)
      .filter((fileName) => fileName.endsWith(".mdx"))
      .map((fileName) => fileName.replace(".mdx", ""));

// Тогда при использовании мы бы сперва «внедрили» system:
const listingQuery = createListingQuery({ system: fs });

// ...А затем бы использовали созданную функцию:
const projectList = listingQuery("projects");

Этот подход «не очень функциональный», но с ним вполне можно работать, если нам не трудно «внедрять» зависимости для каждой такой фабрики, а их использование не доставляет проблем с общим состоянием или эффектами.

Отказ от зависимостей

В «более хардкорном» ФП менять состояние и производить сайд-эффекты не принято. Понятие «зависимостей» в привычном понимании там не очень подходит. Вместо «зависимостей», которые «надо дёргать за методы», и эффектов ФП предлагает функциональное ядро в императивной оболочке.

К слову 🙅
Мне нравится, как эту концепцию Марк Симанн называет — отказ от зависимостей.22 Мы как бы отходим от концепции зависимостей вообще и пробуем решить проблему иначе.

В этом подходе вся работа с состоянием отодвинута к краям приложения. Это значит, что читать и записывать (или выводить на экран) данные можно только в начале работы модуля и в конце. Вся работа между этими точками строится на преобразованиях данных.

То есть мы сперва получаем все нужные данные из «грязного» мира, передаём их как аргументы в цепочку преобразований, а потом записываем результат:

// Выносим функцию с преобразованием
// названий файлов к названиям постов,
// список которых надо вернуть:
const listingQuery = (fileNames) =>
  // Заметьте: нет зависимости от `fs`,
  // мы работаем только с данными:
  fileNames
    .filter((fileName) => fileName.endsWith(".mdx"))
    .map((fileName) => fileName.replace(".mdx", ""));

// «Композиция» теперь — это отдельная функция:
// - она сперва вызывает нужные эффекты, чтобы получить данные,
// - потом прогоняет их через цепочку вычислений,
// - а в конце возвращает результат или вызывает эффект для сохранения данных:
const listingQueryComposition = (query) => {
  // Внутри используем Impureim-бутерброд.
  //
  // 1. Эффект для получения данных.
  //    (Именно из-за присутствия эффектов контекст композиции
  //     и саму функцию композиции считают «нечистыми».)
  const files = fs.readdirSync(query);

  // 2. Логика преобразований в виде цепочки чистых функций.
  return listingQuery(files);

  // 3. Эффект для сохранения данных.
  //    (Если мы не возвращаем результат из функции,
  //     а сохраняем его, то после завершения цепочки преобразований
  //     мы бы вызвали эффект сохранения данных.)
};

С первого взгляда кажется, что стало хуже: для тестов понадобятся глобальные моки, а с «внедрением» можно использовать заглушки на место сервисов. Но в этой концепции юнит-тестами надо тестировать только функциональное ядро — функцию listingQuery. Композицию в простых случаях можно вообще не тестировать, а в более сложных — использовать интеграционные или E2E-тесты.

При использовании же интеграционных тестов такая композиция подтолкнёт нас к архитектуре «Порты-адаптеры», которая поможет уменьшить количество моков, что сделает тесты менее «хрупкими».23

К слову 🔌
Об архитектуре «Порты-адаптеры» мы ещё поговорим в отдельной главе.

Моки нам всё ещё понадобятся для, например, тестирования адаптеров. Но в этом случае на каждый сервис мы напишем лишь один адаптер, а значит и мокать этот сервис нужно будет лишь один раз.

interface System {
  readDirectory(directory: DirectoryPath): List<FileName>;
}

const createAdapter = (service): System => ({
  readDirectory(directory) {
    /*...*/
  },
});

//  test.js

describe("when asked to read the given directory", () => {
  it("should trigger the `readdirSync` method on the service", () => {
    const mock = { readdirSync: jest.fn() };
    const adapter = createAdapter(mock);

    adapter.readDirectory("testdir");
    expect(mock.readdirSync).toHaveBeenCalledWith("testdir");
  });
});

В случае с «внедрением зависимостей» мокать сервис приходилось бы для каждой функции, куда этот сервис внедрён.

Другие способы управления зависимостями

Если в проекте очень много операций ввода-вывода, то тогда «внедрение зависимостей» через частичное применение подойдёт, вероятно, лучше.

Тем не менее, даже если мы собираемся «внедрять» сервисы, это будет сделать гораздо проще, если первым делом мы разделим логику и эффекты. Поэтому в функциональном коде разделение логики и эффектов — это первый рефакторинг, который стоит сделать.

Уточнение 🦄
В ФП используют и другие техники работы с зависимостями.2425 Я в продакшен-коде на JavaScript такое видел только один раз, поэтому не могу сказать, насколько они удобны.
Хороший обзор техник работы с «зависимостями» в ФП описал Скотт Влашин в своём цикле статей “Six approaches to dependency injection”.26 Очень рекомендую по крайней мере первые три статьи.

Целостность и согласованность

В приложениях с состоянием нам также стоит следить за согласованностью и целостностью данных. Это свойства, которые обеспечивают непротиворечивость того, что видит пользователь.

Подробнее 📚
Подробно об этом написал Скотт Влашин в “Domain Modeling Made Functional” в разделе об агрегатах и согласованности данных.27

Агрегаты

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

Рассмотрим на примере. Представим, что в коде интернет-магазина корзина должна всегда иметь правильную итоговую сумму:

// cart.ts
type Cart = {
  items: ProductList;
  total: MoneyAmount;
};

function totalPrice(products: ProductList) {
  return products.reduce(
    (tally, { price, count }) => tally + price * amount,
    0
  );
}

function createCart(products: productList): Cart {
  return {
    items: products,
    total: totalPrice(products),
  };
}

Неправильно изменив объект корзины, мы можем рассогласовать данные. Допустим, какой-то посторонний код добавил новый продукт в список:

// Посторонний код не знает, что после добавления продукта
// надо ещё и пересчитать итоговую цену:
userCart.products.push(appleJuice);

// Теперь поле `cart.total` показывает неправильное значение,
// потому что после добавления продукта оно не было пересчитано.

Кусок данных, которые должны обновляться «как единое целое» — это агрегат. Неизменяемость данных может помочь держать агрегаты согласованными. Она принуждает к тому, чтобы обновление агрегата происходило, начиная с его корневого уровня:

// cart.ts
// ...

function addProduct(cart: Cart, product: Product): Cart {
  const products = [...cart.products, product];
  const total = totalPrice(products);
  return { products, total };
}

// ...

addProduct(userCart, appleJuice);

Функция addProduct гарантирует согласованность, потому что она знает, какие данные и откуда обновлять, чтобы они были валидными. Правильное обновление — её зона ответственности, агрегат — область влияния.

Предвалидация на входе в контекст

Агрегаты и неизменяемость помогают держать данные внутри части приложения валидными и согласованными. А чтобы невалидные данные в него не попали, нужна предвалидация.

Подробнее 👀
В терминах DDD обособленная часть приложения называется ограниченным контекстом. Подробнее об этом понятии мы говорили ранее в главе о функциональном пайплайне.

То есть в компоненте Cart вместо ад-хок проверок на нужные поля:

function Cart({ items }) {
  return (
    !!items && (
      <ul>
        {items.map((item) =>
          item && item.product ? (
            <li key={item.id}>
              {item.product?.name ?? "—"}: {item.product?.price} ×{" "}
              {item.product?.count ?? 0}
            </li>
          ) : null
        )}
      </ul>
    )
  );
}

...Можно сперва провалидировать данные, обработать потенциальные ошибки:

function hasCorruptedItem(item) {
  return !item || !item.product;
}

function validateCart(cart) {
  if (!cart || !cart.items) return Result.failure("EMPTY_CART");
  if (cart.items.some(hasCorruptedItem))
    return Result.failure("CORRUPTED_ITEM");

  // Вместо Result ваш проект может использовать исключения.
  // Подробнее об обработке ошибок мы говорили в предыдущей главе.

  // Альтернативой ошибкам может быть «дефолтная» корзина как результат валидации,
  // но мне всё же кажется, что нарушение контракта API — достаточная причина для ошибки.

  return cart;
}

...А внутри компонента использовать уже валидные и проверенные данные:

// ...

function Cart({ items }) {
  return (
    <ul>
      {items.map(({ id, product }) => (
        <li key={id}>
          {product.name}: {product.price} × {product.count}
        </li>
      ))}
    </ul>
  );
}

Footnotes

  1. Зацепление в программировании, Википедия, https://ru.wikipedia.org/wiki/Зацепление_(программирование)

  2. Разделение ответственности, Википедия, https://ru.wikipedia.org/wiki/Разделение_ответственности

  3. Связность в программировании, Википедия, https://ru.wikipedia.org/wiki/Связность_(программирование)

  4. Feature Envy, Refactoring Guru, https://refactoring.guru/smells/feature-envy

  5. “Design By Contract”, c2.com, https://wiki.c2.com/?DesignByContract 2

  6. «Контрактное программирование» Тимур Шемсединов, https://youtu.be/K5_kSUvbGEQ

  7. Strategy Pattern, Refactoring Guru, https://refactoring.guru/design-patterns/strategy

  8. “Inversion of Control Containers and the Dependency Injection pattern” by Martin Fowler, https://martinfowler.com/articles/injection.html 2

  9. Внедрение зависимостей с TypeScript на практике, https://bespoyasov.ru/blog/di-ts-in-practice/ 2

  10. REpresentational State Transfer, REST, https://restfulapi.net

  11. Message Broker, Microsoft Docs, https://docs.microsoft.com/en-us/previous-versions/msp-n-p/ff648849(v=pandp.10)

  12. Message Bus, Microsoft Docs, https://docs.microsoft.com/en-us/previous-versions/msp-n-p/ff647328(v=pandp.10)

  13. Message Queue, Wikipedia, https://en.wikipedia.org/wiki/Message_queue

  14. Observer Pattern, Refactoring Guru, https://refactoring.guru/design-patterns/observer

  15. RxJS, Reactive Extensions Library for JavaScript, https://rxjs.dev

  16. The Principles of OOD, Robert C. Martin, http://www.butunclebob.com/ArticleS.UncleBob.PrinciplesOfOod 2

  17. “Dependency Injection in .NET” by Mark Seemann, https://www.goodreads.com/book/show/9407722-dependency-injection-in-net

  18. “Domain-Driven Design” by Eric Evans, https://www.goodreads.com/book/show/179133.Domain_Driven_Design

  19. “Evans Classification” by Martin Fowler, https://martinfowler.com/bliki/EvansClassification.html

  20. “Functional architecture: The pits of success” by Mark Seemann, https://youtu.be/US8QG9I1XW0

  21. “Command-Query Separation” by Martin Fowler, https://martinfowler.com/bliki/CommandQuerySeparation.html

  22. “Dependency Rejection” by Mark Seemann, https://blog.ploeh.dk/2017/02/02/dependency-rejection/

  23. “Unit Testing: Principles, Practices, and Patterns” by Vladimir Khorikov, https://www.goodreads.com/book/show/48927138-unit-testing

  24. “Dependency Injection Using the Reader Monad” by Scott Wlaschin, https://fsharpforfunandprofit.com/posts/dependencies-3/

  25. “Dependency Interpretation” by Scott Wlaschin, https://fsharpforfunandprofit.com/posts/dependencies-4/

  26. “Six approaches to dependency injection” by Scott Wlaschin, https://fsharpforfunandprofit.com/posts/dependencies/

  27. “Domain Modeling Made Functional” by Scott Wlaschin, https://www.goodreads.com/book/show/34921689-domain-modeling-made-functional