Часть 4. Порты, адаптеры и инфраструктура

Продолжаем серию постов и экспериментов о разработке и проектировании приложений. В прошлый раз мы написали компоненты для пользовательского интерфейса и обсудили взаимодействие UI с ядром приложения. В этом посте мы подготовим инфраструктуру для проекта: напишем стор для данных модели и создадим сервис для запросов к API.

Выходные порты приложения

Если в предыдущий раз UI «стучался» в приложение через входные порты, то с инфраструктурой приложение будет общаться через выходные порты: типы FetchRates, ReadConverter и SaveConverter.

Выходные порты — «рычажки» в ядре приложения с «другой стороны». Они описывают, какая «сервисная» функциональность нужна ядру приложения для решения какой-то задачи.

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

Сервис работы с API

Первый сервис, который нам потребуется, — это модуль для общения с сервером API. Мы могли бы описать функцию запросов к серверу как-то так:

// services/network

export async function get<T>(url: Url): Promise<T> {
	const absolute = new URL(url, config.base);
	const response = await fetch(absolute);
	if (!response.ok) throw new Error('Failed to perform the request.');

	return await response.json();
}

Функция get составляет URL-адрес API-эндпоинта, запускает под капотом браузерный fetch и парсит из JSON ответа сервера какие-то данные.

Заметим, что код этой функции обобщённый. Она не знает, какие именно данные придут с сервера, за исключением того, что это будет JSON.

Отличительная особенность сервисов как раз в том, что они концептуально не связаны с предметной областью и могут работать с любым приложением. Функция вроде get может кочевать из проекта в проект, находясь в какой-нибудь папке lib или shared.

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

type ApiRequest<R> = (url: Url) => Promise<R>;

Тип ApiRequest<T> не затрагивает высокоуровневых концепций приложения. Он выражается терминами низкого уровня: «запрос», «API», «URL-адрес». Он даже не знает, какие именно данные будет получать от API, вместо этого он использует тип-аргумент R, который говорит, что конкретные данные для этой функции не важны — важен механизм, схема работы и общения с сервером.

Из-за обобщения сервисы можно переиспользовать в разных проектах:

Сервисы напрямую не связаны с конкретным приложением, они могут переиспользоваться в разных проектах с помощью адаптеров
Сервисы напрямую не связаны с конкретным приложением, они могут переиспользоваться в разных проектах с помощью адаптеров

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

Адаптер к сервису API

Всю работу адаптера для сервиса API мы можем поделить на 3 этапа:

  • получить данные от API — вызвать внешний сервис get;
  • преобразовать данные к формату доменной модели — десериализовать ответ API;
  • передать форматированные данные в ядро приложения — реализовать выходной порт FetchRates.

Допустим, мы знаем, что сервер отдаёт нам данные в формате:

{
	"rates": {
		"RPC": {
			"IMC": 0.98,
			"WPU": 1.23,
			"DRG": 2.2,
			"ZKL": 1.07
		},
		"IMC": { "//": "..." },
		"WPU": { "//": "..." },
		"DRG": { "//": "..." },
		"ZKL": { "//": "..." }
	}
}

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

RefreshRates:
  API -> ServerRates
  ServerRates -> ExchangeRates
  ExchangeRates -> Application Core

Напишем функцию fetchRates, которая будет реализовывать тип FetchRates:

// infrastructure/api

export const fetchRates: FetchRates = async () => {
	// TODO:
	// 1. Вызвать сервис API.
	// 2. Преобразовать формат.
	// 3. Вернуть данные.
};

…И теперь реализуем каждый из шагов.

Десериализация данных

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

// infrastructure/api.serialization

type ServerRates = { rates: Record<BaseCurrencyCode, ExchangeRates> };
const toDomain = (dto: ServerRates): ExchangeRates => dto.rates.RPC;

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

Смысл функции toDomain — в инкапсуляции знаний о том, как серверные данные превратить в модель. Когда такой десериализатор явно выделен в коде, нам проще искать место, куда нужно внести изменения, если схема данных на сервере меняется.

Более того, с явно выделенной десериализацией мы можем поддерживать несколько схем ответа API одновременно:

// infrastructure/api.v1.serialization.ts
type ServerRates = { rates: Record<BaseCurrencyCode, ExchangeRates> };
const toDomain = (dto: ServerRates): ExchangeRates => dto.rates.RPC;

// infrastructure/api.v2.serialization.ts
type ServerRates = { default: [BaseCurrencyCode, ExchangeRates] };
const toDomain = (dto: ServerRates): ExchangeRates =>
	dto.default.find(([key]) => key === 'RPC').at(1);

Ответ от сервера, который мы описываем типов ServerRates, — это так называемый объект передачи данных, DTO. Мы не будем подробно останавливаться на этом понятии, но у Скотта Влашина в его книге «Доменное моделирование в функциональном стиле» есть отдельная глава о десериализации и работе с DTO. Рекомендую прочитать.

Используя десериализатор, мы можем заполнить 2-й шаг функции fetchRates:

// infrastructure/api

import { toDomain } from './api.serialization';

const fetchRates: FetchRates = async () => {
	// TODO:
	// 1. Получить данные от API.

	// 2. Преобразовать данные к домену:
	const data = toDomain(response);

	// 3. Реализовать контракт на `FetchRates`:
	return data;
};

Вызов сервиса

Далее обратимся к непосредственно сервису и получим данные от API:

// infrastructure/api

import type { FetchRates } from '../../core/ports.output';
import type { ServerRates } from './api.serialization';
import { toDomain } from './api.serialization';

import { get } from '~/services/network';

const fetchRates: FetchRates = async () => {
	// 1. Получить данные от API:
	const response = await get<ServerRates>('/rates');

	const data = toDomain(response);
	return data;
};

Заметим, что URL-адрес эндпоинта мы держим прямо в этом модуле, а не в сервисе network. Причина в том, что сервис должен оставаться переиспользуемым и независимым от предметной области и конкретного проекта.

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

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

Тестирование, моки и зависимости

Чтобы протестировать такой адаптер, нам потребуется создать мок для модуля ~/services/network и проверить, что функция get вызывается с нужными параметрами.

// infrastructure/api.test

const spy = vi.fn(() => serverStub);
vi.mock('~/services/network', () => ({ get: spy }));

// ...

it('triggers API call', async () => {
	await fetchRates();
	expect(spy).toHaveBeenCalledOnce();
	expect(spy).toHaveBeenCalledWith('/rates');
});

Использовать моки для подмены зависимостей — это вполне валидный вариант в JS и TS коде. В реальных тестах функций, работающих с сайд-эффектами, мы скорее всего будем видеть именно моки. Однако, если мы пишем код «по книжкам» и наша цель — сделать зависимости функции явными, мы можем их «запечь».

Частичное применение и явные зависимости

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

Так модули расцепляются друг от друга, потому между ними появляется «прослойка» из DI-контейнера — штуки, которая автоматически подставляет нужные конкретные зависимости в места, где объявлены их интерфейсы. В ООП-коде это помогает решить проблему компоновки объектов и связанных с их работой сайд-эффектов.

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

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

// core/changeQuoteCode

// Объявляем типы всех зависимостей,
// которые потребуются функции:
type Dependencies = { saveConverter: SaveConverter };

// Получаем к ним доступ из аргумента функции `deps`:
export const changeQuoteCode = (quoteCode, deps: Dependencies) => {
	// Используем их внутри функции,
	// зная, что они реализуют конкретный тип:
	saveConverter({ quoteCode });
};

Зависимости в таком варианте передавались последним аргументом:

const result = changeQuoteCode(realArgument, dependencies);

Но такая передача вызывает противоречие: нам либо надо всегда требовать передать аргумент с зависимостями, либо делать его необязательным. Ни то, ни то не удобно и недостаточно надёжно.

Решить это противоречие можно, заставив функцию «запоминать» ссылки на зависимости:

const result = changeQuoteCode(realArgument); // + Remembered Dependencies

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

const createChangeQuoteCode = (dependencies) => {
	// Return another function that has access to `dependencies`
	// because it's in the parent scope which is visible to it.
	return (realArgument) => {
		// Do the stuff with `realArgument` AND `dependencies`.
	};
};

А затем частично применить функцию createChangeQuoteCode, чтобы получить в результате функцию с «запомненными» зависимостями:

// Returns a function with “remembered” dependencies:
const changeQuoteCode = createChangeQuoteCode(dependencies);

// Return the result of calling that function:
const result = changeQuoteCode(realArgument);

Такую технику работы с зависимостями иногда называют «запеканием» (“baking” dependencies). Именно её мы используем, чтобы подготовить адаптер к API.

«Запекаем» адаптер

Чтобы запечь зависимости, создадим функцию-фабрику, которая будет принимать зависимости адаптера и возвращать FetchRates:

import type { FetchRates } from '../../core/ports.output';
import type { ServerRates } from './api.serialization';

type Dependencies = { request: ApiRequest<ServerRates> };
type CreateFetchRates = (dependencies: Dependencies) => FetchRates;

В коде мы можем это выразить так:

// infrastructure/api

import { toDomain } from './api.serialization';

// Реализуем «фабрику», которая принимает зависимости,
// а возвращает «настроенную» функцию-адаптер:

const createFetchRates: CreateFetchRates =
	({ request }) =>
	async () => {
		const response = await request('/rates');
		const data = toDomain(response);
		return data;
	};

Тогда, для создания адаптера и его настройки, мы вызовем фабрику и передадим туда объект с настоящим сервисом:

// infrastructure/api.composition

import type { FetchRates } from '../../core/ports.output';
import { get } from '~/services/network';

export const fetchRates: FetchRates = createFetchRates({ request: get });

Заметим, что работа функции, которую возвращает createFetchRates, зависит только от типов сервисов. Подстановку конкретных реализаций сервисов мы проводим отдельно — во время композиции. Намерение (работа функции) и реализация (композиция) снова оказываются разделены, что делает модули независимее.

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

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

  • внутренностей этого же модуля;
  • типов всего остального.
// infrastructure/api

// Если мы используем что-то снаружи модуля,
// то импортируем только типы:
import type { ApiRequest } from '~/shared/kernel';
import type { FetchRates } from '../../core/ports.output';

// Из самого модуля мы можем импортировать что угодно:
import type { RatesDTO } from './api.serialization';
import { toDomain } from './api.serialization';

export const createFetchRates =
	({ request }: Dependencies): FetchRates =>
	async () => {
		// ...
	};

Такое «отгораживание» в других модулей через абстракцию помогает избежать лишнего зацепления. У модуля появляется одна понятная входная точка для остальных модулей — публичный интерфейс:

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

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

Подстановка конкретных реализаций же — увеличение зацепления — происходит на последнем этапе, при композиции модуля:

// infrastructure/api.composition

import { createFetchRates } from './api';
import { get as request } from '~/services/network';

export const fetchRates: FetchRates = createFetchRates({ request: get });

Ну и в конце, чтобы скрыть то, что не должно быть доступно снаружи, мы можем настроить ре-экспорты через index.ts:

// api/index

export * from './api.composition';

Тогда из всех деталей и структуры модуля:

infrastructure/api/
  - api.ts                — реализация и фабрика функции;
  - api.serialization.ts  — сериализация данных;
  - api.test.ts           — тесты к реализации;
  - api.composition.ts    — композиция продакшен-версии;
  - index.ts

…Снаружи будет доступно только содержимое файла api.composition.ts.

Стор как сервис

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

Порты и реализация

Начнём с обзора требуемой функциональности. Ядро приложения требует от стора 2 функции: для чтения и сохранения данных модели.

// core/ports.output.ts

type ReadConverter = () => Converter;
type SaveConverter = (patch: Partial<Converter>) => void;

Для реализации мы можем создать контекст с таким типом:

// infrastructure/store

// Тип, описывающий хранилище, приватный.
// Это помогает соседним модулям не зависеть
// от деталей реализации стора.
type Store = {
	value: Converter;
	update: SaveConverter;

	// Как вариант, можем дополнительно
	// прям явно создать функцию для чтения данных,
	// но в случае с контекстом это не обязательно.
	read: ReadConverter;
};

const ConverterContext = createContext<Nullable<Store>>(null);
export const useStore = () => useContext(ConverterContext);

Далее создадим провайдер:

// infrastructure/store

export const StoreProvider = ({ children }: PropsWithChildren) => {
	const [value, setValue] = useState<Converter>(initialModel);

	// Если мы используем отдельную функцию для чтения
	// (это не обязательно):
	const read = () => value;

	const update: StoreWriter = (patch) => setValue((state) => ({ ...state, ...patch }));

	return (
		<ConverterContext.Provider value={{ value, read, update }}>
			{children}
		</ConverterContext.Provider>
	);
};

Композиция сервиса

Нам осталось ассоциировать типы портов приложения и реализацию конкретных функций — «зарегистрировать» сервис:

// infrastructure/store.composition

import type { ReadConverter, SaveConverter } from '../../core/ports.output';
import { StoreProvider, useStore } from './store';

export const useStoreWriter: Provider<SaveConverter> = () => useStore().update;
export const useConverter: ReadConverter = () => useStore().value;

// Или если нам понадобится функция для чтения:
export const useStoreReader: Provider<ReadConverter> = () => useStore().read;

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

Реализация мимо ядра

Кроме обновления конвертера через SaveConverter, нам в UI также нужно читать данные из хранилища:

// shared/kernel.ts
type Selector<T> = () => T;

// core/ports.input.ts
type SelectBaseValue = Selector<BaseValue>;
type SelectQuoteValue = Selector<QuoteValue>;
type SelectQuoteCode = Selector<QuoteCurrencyCode>;

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

// infrastructure/store.composition

import type { SelectBaseValue, SelectQuoteCode, SelectQuoteValue } from '../../core/ports.input';

const useValueBase: SelectBaseValue = () => useStore().value.baseValue;
const useQuoteCode: SelectQuoteCode = () => useStore().value.quoteCode;
const useValueQuote: SelectQuoteValue = () => useStore().value.quoteValue;

Такой «фаст-трек» мимо ядра приложения — частая история в приложениях, где мало или вовсе нет доменной логики.

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

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

Почти всё вместе

Приложение почти готово. Мы создали модель и проработали пользовательские сценарии, создали UI-слой и нужные компоненты, описали сервисы для запросов к API и хранения данных. Теперь нам осталось собрать это всё вместе с рабочий проект, чем мы и займёмся в следующий раз.

В следующий раз

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

Ссылки

Все ссылки на книги, статьи и другие материалы, упомянутые в статье.

Паттерны и принципы

Функциональное программирование

Работа с зависимостями и архитектура

Контекст и рендеринг в Реакте

Прочее

Другие части серии