Skip to content

Latest commit

 

History

History
644 lines (471 loc) · 43.6 KB

11-side-effects.md

File metadata and controls

644 lines (471 loc) · 43.6 KB

Сайд-эффекты

Любое взаимодействие программы с внешним миром — это сайд-эффект. Сохранение данных на сервере, вывод на экран, доступ к браузерному API — всё это эффекты. Они необходимы, чтобы написать полезное приложение, но работать с ними сложно и чревато ошибками.

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

Чистые функции

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

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

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

Ссылочная прозрачность

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

Укажем схематично проблемный кусок кода,
ошибку в котором отметим как «X»:

[....................X...........]

1.
Чтобы найти ошибку в этом куске,
поделим функциональность пополам
и проверим каждую половину:

[...............|....X...........]

2.
Если левая половина работает нормально,
значит — ошибка в правой:

[               |....X...........]

3.
Делим правую половину пополам
и проверяем каждую часть в ней:

[               |....X..|........]

4.
Правая часть работает нормально,
значит — ошибка в левой:

[               |....X..|        ]

5.
Снова делим проблемный участок пополам,
проверяем по очереди каждую часть
и так далее, пока не найдём точное место с ошибкой:

[               |...|X..|        ]
[                   |X..|        ]
[                   |X|.|        ]
[                   |X|          ]

С каждой итерацией проблемный фрагмент кода
уменьшается в два раза — это сильно экономит время.

Как правило, через 3–6 итераций становится понятно,
в чём именно проблема и где она находится.
К слову 🔪
Иногда такой процесс двоичного поиска ещё называют бисекцией по аналогии с бисекцией в гите.1

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

То есть мы буквально можем заменить вызовы функций до «разреза» на результаты их работы, и это не изменит поведения программы. Такое свойство называется ссылочной прозрачностью (Referential Transparency),2 и оно делает поиск ошибок и тестирование намного проще.

Последовательность прямоугольников, подписанных как «Яблоки → Бананы», «Бананы → Вишни» и «Вишни → Ананасы» обрублена перед последним прямоугольником; всё, что находится до этого места, заменено на просто «Вишни»

Ссылочная прозрачность позволяет заменить вызов функции результатом её работы

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

Неизменяемость по умолчанию

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

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

function prepareExport(items) {
  let latestShipmentDate = 0;

  for (const item of items) {
    item.subtotal = item.price * item.count;

    if (item.shipmentDate >= latestShipmentDate) {
      latestShipmentDate = item.shipmentDate;
    }
  }

  for (const item of items) {
    item.shipmentDate = latestShipmentDate;
  }

  return items;
}

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

Это значит, например, что определяя дату отправки, нам придётся думать, как изменения в items отразятся на подсчёте итоговой стоимости. Чем объёмнее функция, тем больше действий затронет эффект, тем больше деталей придётся держать в голове.

Более того, так как items — массив, его изменения будут видны и снаружи функции prepareExport. Эффект будет влиять на код вне функции, о котором мы можем ничего не знать. В этом случае предусмотреть все потенциальные проблемы будет вовсе невозможно.

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

Результатом каждого шага будет новый набор данных, а сами шаги не будут влиять друг на друга через общее состояние:

function prepareExport(items) {
  // 1. Посчитаем подытог.
  //    Результат представим как новый массив.
  const withSubtotals = items.map((item) => ({
    ...item,
    subtotal: item.price * item.count,
  }));

  // 2. Посчитаем дату отправки.
  //    В качестве исходных данных будем
  //    использовать результат прошлого шага.
  let latestShipmentDate = 0;
  for (const item of withSubtotals) {
    if (item.shipmentDate >= latestShipmentDate) {
      latestShipmentDate = item.shipmentDate;
    }
  }

  // 3. Проставим дату в каждую позицию.
  //    Результат снова представим
  //    в виде нового массива.
  const withShipment = items.map((item) => ({
    ...item,
    shipmentDate: latestShipmentDate,
  }));

  // 4. Вернём результат шага 3,
  //    в качестве результата всей задачи.
  return withShipment;
}

Шаги мы можем вынести в отдельные функции и, если требуется, отрефакторить каждую отдельно:

function calculateSubtotals(items) {
  return items.map((item) => ({ ...item, subtotal: item.price * item.count }));
}

function calculateLatestShipment(items) {
  const latestDate = Math.max(...items.map((item) => item.shipmentDate));
  return items.map((item) => ({ ...item, shipmentDate: latestDate }));
}

Тогда начальная функция prepareExport будет выглядеть как результат последовательности из преобразований данных:

function prepareExport(items) {
  const withSubtotals = calculateSubtotals(items);
  const withShipment = calculateLatestShipment(withSubtotals);
  return withShipment;
}

// items -> withSubtotals -> withShipment

Или даже так, если мы используем Hack Pipe Operator, который на момент написания находится в Stage 2:3

const prepareExport =
  items |> calculateSubtotals(%) |> calculateLatestShipment(%);

Выделять шаги в отдельные функции нужно не всегда, но это удобно в нескольких случаях:

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

Заметим, что в новой реализации функция prepareExport не меняет исходные данные. Вместо этого она создаёт копию данных и меняет её. Массив items остаётся неизменным, что предотвращает ошибки в коде снаружи функции.

Шаги внутри prepareExport теперь связаны друг с другом только через свои входные и выходные данные. У них больше нет общего состояния, которое могло бы повлиять на их работу. Благодаря этому нам проще строить в голове модель работы всей функции prepareExport в целом.

Абстракция и инкапсуляция 👀
Абстрагируя каждый шаг в отдельную функцию с понятным именем, мы делаем смысл всей цепочки более явным. Это помогает концентрироваться на отдельных шагах, не отвлекаясь на соседние. Изоляция же помогает на каждом шаге обеспечить валидность данных, потому что не даёт повлиять на работу функции «снаружи».

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

К слову 🚜
В JavaScript с настоящей неизменяемостью туго. Для действительно неизменяемых объектов понадобится Object.freeze, который используют довольно редко.
Но «настоящая» неизменяемость не всегда нужна. Чаще всего достаточно писать и воспринимать код так, будто он работает с неизменными данными.

Функциональное ядро в императивной оболочке

Неизменяемость и чистые преобразования — это хорошо, но, как мы упоминали в самом начале, совсем без эффектов обойтись нельзя. Взаимодействие с внешним миром — получение и сохранение данных или вывод их в UI — это всегда эффекты. Без такого взаимодействия приложение будет бесполезным.

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


❗️ Минимизировать количество эффектов и изолировать их от другого кода


Техника, которую мы можем использовать для управления эффектами, называется функциональное ядро в императивной оболочке или Impureim Sandwich.45 При использовании этого подхода логику приложения мы описываем в виде чистых функций, а всё «нечистое» взаимодействие с внешним миром отодвигаем к краям приложения.

Три окрашенных слоя: два внешних отмечены как «Нечистые», внутренний отмечен как «Чистый»

Получается такой бутерброд: эффект для получения данных; логика без эффектов; эффект для сохранения данных

Рассмотрим подход на примере функции updateUserInfo. Сейчас преобразования данных в ней перемешаны с их получением и сохранением:

function updateUserInfo(event) {
  const { email, birthYear, password } = event.target;
  const root = document.querySelector(".userInfo");

  root.querySelector(".age").innerText = new Date().getFullYear() - birthYear;
  root.querySelector(".password").innerText = password.replace(/./g, "*");
  root.querySelector(".login").innerText = email.slice(
    0,
    email.lastIndexOf("@")
  );
}

Попробуем отделить логику от эффектов. Сперва мы можем это сделать прямо внутри функции, сгруппировав код в «кучки»:

function updateUserInfo(event) {
  // Получаем данные:
  const { email, birthYear, password } = event.target;

  // Преобразуем их:
  const age = new Date().getFullYear() - birthYear;
  const username = email.slice(0, email.lastIndexOf("@"));
  const hiddenPassword = password.replace(/./g, "*");

  // «Сохраняем», в нашем случае отображаем их в UI:
  const root = document.querySelector(".userInfo");
  root.querySelector(".age").innerText = age;
  root.querySelector(".password").innerText = hiddenPassword;
  root.querySelector(".login").innerText = username;
}

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

function toPublicAccount({ email, birthYear, password, currentYear }) {
  return {
    age: currentYear - birthYear,
    username: email.slice(0, email.lastIndexOf("@")),
    hiddenPassword: password.replace(/./g, "*"),
  };
}

Тогда использовать функцию toPublicAccount внутри updateUserInfo можно будет таким образом:

function updateUserInfo(event) {
  // Получаем данные:
  const { email, birthYear, password } = event.target;
  const currentYear = new Date().getFullYear();

  // Преобразуем их:
  const { age, username, hiddenPassword } = toPublicAccount({
    email,
    birthYear,
    password,
    currentYear,
  });

  // «Сохраняем»:
  const root = document.querySelector(".userInfo");
  root.querySelector(".age").innerText = age;
  root.querySelector(".password").innerText = hiddenPassword;
  root.querySelector(".login").innerText = username;
}

Мы можем проверить, стал ли код лучше, если напишем тесты на преобразования данных.6 В первой версии кода, тест получился бы таким:

// 1. В случае с функцией `updateUserInfo`
//    приходится создавать моки для DOM и Event:
const dom = jsdom(/*...*/);
const event = {
  target: {
    email: "[email protected]",
    password: "strong-password-1234",
    birthYear: 1994,
  },
};

// ...Нужно создавать мок для текущей даты,
//    чтобы результат теста был воспроизводимым:
jest.useFakeTimers().setSystemTime(new Date("2022-01-01"));

// ...Также надо следить, чтобы все моки и таймеры
//    сбрасывались и не влияли на другие тесты:
afterAll(() => jest.useRealTimers());

describe("when given a user info object", () => {
  it("should calculate the user age", () => {
    updateUserInfo(event);

    // ...Проверять работу приходится по содержимому DOM-узла:
    const node = dom.document.querySelector(".userInfo .age");

    // ...Тип данных при этом теряется,
    //    потому что DOM-узлы содержат только строки:
    expect(node.innerText).toEqual("28");
  });
});

Теперь напишем тест для функции toPublicAccount и сравним с предыдущим:

// 2. В случае с `toPublicAccount` моки не нужны,
//    так как для тестирования чистой функции
//    нужны только входные данные и ожидаемый результат.

describe("when given a user info object", () => {
  it("should calculate the user age", () => {
    const { age } = toPublicAccount({
      email: "[email protected]",
      password: "strong-password-1234",
      birthYear: 1994,
      currentYear: 2022,
    });

    // Проверить результат мы можем прямым сравнением,
    // без моков, не потеряв при этом тип данных:
    expect(age).toEqual(28);
  });
});

В первом случае функция updateUserInfo занимается разными задачами: преобразованием данных и взаимодействием с UI. Её тесты это подтверждают: они проверяют, как изменились данные, но при этом создают моки для DOM.

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

Во втором случае тест проще, потому что ему не нужны моки и таймеры. Для тестирования чистых функций нам нужны лишь входные данные и ожидаемый результат. (Поэтому часто говорят, что чистые функции — тестируемы по своей природе.)

Взаимодействие с DOM при этом становится отдельной задачей. Моки для DOM будут появляться в тестах модуля, который будет заниматься взаимодействием с UI, и больше нигде.

Мы таким образом не только упростили код функции, но и улучшили разделение ответственности между разными частями приложения.

К слову 🔌
Уточню, что смысл не в том, будто «моки — это всегда плохо», нет. Иногда моки — единственный способ протестировать желаемый эффект, например, в адаптерах.6
Смысл в том, что если для тестирования логики приходится писать мок, то скорее всего есть способ упростить код, а эффекты вынести за пределы функции или модуля.

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

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

Адаптеры для эффектов

Разделение логики и эффектов помогает обнаружить дублирование в получении и сохранении данных. Это может быть заметно по одинаковым мокам в тестах или похожему коду в самих эффектах.

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

// Если мы замечаем одинаковую функциональность
// при получении или сохранении данных:

function updateUserInfo(user) {
  // ...

  if (window?.localStorage) {
    window.localStorage.setItem("user", JSON.stringify(user));
  }
}

function updateOrder(order) {
  // ...

  if (window?.localStorage) {
    window.localStorage.setItem("order", JSON.stringify(order));
  }
}

// Мы можем вынести её в адаптер:

const storageAdapter = {
  update(key, value) {
    if (window?.localStorage) {
      window.localStorage.setItem(key, JSON.stringify(value));
    }
  },
};

// И использовать уже только его:

function updateUserInfo(user) {
  // ...
  storageAdapter.update("user", user);
}

function updateOrder(order) {
  // ...
  storageAdapter.update("order", order);
}

// Теперь весь доступ к хранилищу описан в `storageAdapter`,
// и нам достаточно протестировать работу с хранилищем только в нём,
// не дублируя проверки в функциях типа `updateUserInfo`.
Подробнее 👀
Подробнее об адаптерах мы поговорим ещё отдельно в главе об архитектуре.

Команды и запросы

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

  • одни — получают информацию из него;
  • другие могут добавлять, обновлять и удалять её.

Разделение кода, ответственного за эти задачи, — это разделение на команды и запросы (Command-Query Separation, CQS).7

Согласно CQS запросы возвращают данные и не производят эффектов, а команды меняют состояние внешнего мира и ничего не возвращают. По сути цель CQS:


❗️ Разделить чтение и запись информации


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

Для примера, посмотрим на сигнатуру функции getLogEntry:

function getLogEntry(id: Id<LogEntry>): LogEntry {}

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

function getLogEntry(id: Id<LogEntry>): LogEntry {
  const entry =
    logger.getById(id) ?? logger.createEntry(id, Date.now(), "Access");

  return entry;
}

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

function getOrCreateLogEntry(id: Id<LogEntry>): LogEntry {}

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

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

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

afterEach(() => jest.clearAllMocks());
afterAll(() => jest.restoreAllMocks());

describe("when given an ID that exists in the service", () => {
  it("should return the entry with that ID", () => {
    jest.spyOn(logger, "getById").mockImplementation(() => testEntry);
    const result = getOrCreateLogEntry("test-entry-id");
    expect(result).toEqual(testEntry);
  });
});

describe("when given an ID of an entry that doesn't exist", () => {
  it("should create a new entry with the given ID", () => {
    jest.spyOn(logger, "getById").mockImplementation(() => null);
    const spy = jest.spyOn(logger, "createEntry");

    const result = getOrCreateLogEntry("test-entry-id");
    expect(spy).toHaveBeenCalledWith("test-entry-id", timeStub, "Access");

    expect(result).toEqual({
      createdAt: timeStub,
      id: "test-entry-id",
      type: "Access",
    });
  });
});

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

Мы можем разделить эту функцию на две:

function readLogEntry(id: Id<LogEntry>): MaybeNull<LogEntry> {}
function createLogEntry(id: Id<LogEntry>): void {}

По сигнатурам мы теперь видим, что первая функция возвращает результат, а вторая — что-то делает, но ничего не возвращает. Уже это подталкивает к выводу, что вторая функция меняет состояние — то есть является эффектом.

function readLogEntry(id: Id<LogEntry>): MaybeNull<LogEntry> {
  return logger.getById(id) ?? null;
}

function createLogEntry(id: Id<LogEntry>): void {
  logger.createEntry(id, Date.now(), "Access");
}

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

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

// readLogEntry.test.ts

describe("when given an ID", () => {
  it("should call the logger service with that ID", () => {
    const spy = jest.spyOn(logger, "getById");
    readLogEntry("test-entry-id");
    expect(spy).toHaveBeenCalledWith("test-entry-id");
  });
});

// createLogEntry.test.ts

describe("when given an ID", () => {
  it("should call the logger service create with that ID and default entry data", () => {
    const spy = jest.spyOn(logger, "createEntry");
    createLogEntry("test-entry-id");
    expect(spy).toHaveBeenCalledWith("test-entry-id", timeStub, "Access");
  });
});
Упрощение 🚧
Часто для такого рода адаптеров нужно ещё протестировать, что они корректно преобразуют результат работы сервиса к нашим требованиям («адаптируют интерфейс»). Но конкретно в этом примере я посчитал это лишним и не стал заострять на этом внимания.

CQS и сгенерированные ID

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

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

Если же от CQS отступать не хочется, можно передавать ID вместе с данными сущности, которые надо сохранить.

Подробнее 👀
Подробно об этом решении писал Марк Симанн в статье “CQS versus server generated IDs”.8 В ней он детально объясняет само решение, его вариации, применимость и недостатки.

CQRS

Говоря о бекенде, стоит вспомнить о CRUD-операциях и CQRS.910 При проектировании API может возникнуть желание использовать одинаковые модели для чтения и записи данных:

type UserModel = {
  id: Id<User>;
  name: FullName;
  birthDate: DateTime;
  role: UserRole;
};

function readUser(id: Id<User>): UserModel {}
function updateUser(user: UserModel): void {}

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

Функция updateUser требует на вход весь объект UserModel, поэтому обновить отдельные поля мы не можем. Нам придётся передавать в функцию обновлённый объект целиком.

Если мы в проекте столкнулись с такой проблемой, то стоит вспомнить о Command-Query Responsibility Segregation, CQRS.10 Этот принцип расширяет идею CQS, предлагая использовать разные модели для чтения и записи данных. Продолжая пример с UserModel, мы можем выразить суть CQRS таким образом:

// Для чтения используем один тип, `ReadUserModel`:
function readUser(id: Id<User>): ReadUserModel {}

// Для записи — другой, `UpdateUserModel`:
function updateUser(user: UpdateUserModel): void {}

Независимые модели «развязывают руки» в том, какие данные передавать при записи и какие данные ожидать при чтении. Например, мы можем описать тип ReadUserModel как набор обязательных полей, которые обязаны быть в получаемых данных:

type ReadUserModel = {
  id: Id<User>;
  name: FullName;
  birthDate: DateTime;
  role: UserRole;
};

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

type UpdateUserModel = {
  // ID обязателен, чтобы было понятно,
  // данные какого пользователя обновлять:
  id: Id<User>;

  // Всё остальное опционально,
  // чтобы обновить только то, что нужно:
  name?: FullName;
  birthDate?: DateTime;

  // Роль, например, обновить вовсе нельзя,
  // поэтому этого поля здесь нет совсем.
};
Будьте внимательны 🚧
CQRS увеличивает количество кода (моделей, объектов, типов) в проекте. Перед использованием его стоит обсудить с командой и убедиться, что нет аргументов против.
Основные причины для использования CQRS — это отличия в структурах данных для чтения и записи, а также разница в нагрузке и необходимость в раздельном масштабировании чтения и записи.
В остальных случаях, вероятно, будет проще и дешевле использовать общую модель.

Footnotes

  1. git-bisect, Use binary search to find the commit that introduced a bug, https://git-scm.com/docs/git-bisect

  2. Referential Transparency, Haskell Wiki, https://wiki.haskell.org/Referential_transparency

  3. “A pipe operator for JavaScript” by Axel Rauschmayer, https://2ality.com/2022/01/pipe-operator.html

  4. “Functional Core in Imperative Shell” by Gary Bernhardt, https://www.destroyallsoftware.com/screencasts/catalog/functional-core-imperative-shell

  5. “Impureim Sandwich” by Mark Seemann, https://blog.ploeh.dk/2020/03/02/impureim-sandwich/

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

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

  8. “CQS versus server generated IDs” by Mark Seemann, https://blog.ploeh.dk/2014/08/11/cqs-versus-server-generated-ids/

  9. CRUD, Википедия, https://ru.wikipedia.org/wiki/CRUD

  10. “Command-Query Responsibility Segregation” by Martin Fowler, https://martinfowler.com/bliki/CQRS.html 2