Часть 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 и хранения данных. Теперь нам осталось собрать это всё вместе с рабочий проект, чем мы и займёмся в следующий раз.
В следующий раз
Сегодня мы реализовали всю инфраструктуру приложения и привязали её к выходным портам приложения. В следующем посте мы соберём целиком приложение из его частей, используем хуки как способ и контекст композиции, а также подумаем, какие ещё способы могут быть.
Ссылки
Все ссылки на книги, статьи и другие материалы, упомянутые в статье.
Паттерны и принципы
- Factory Pattern
- Инверсия управления, Википедия
- Объект передачи данных, Википедия
- Сериализация, Википедия
Функциональное программирование
- Curried functions
- Dependency rejection
- Domain Modelling Made Functional. Scott Wlaschin
- Частичное применение функций
Работа с зависимостями и архитектура
- Reflecting architecture and domain in code
- Six approaches to dependency injection
- What is a DI Container?
- Внедрение зависимостей с TypeScript на практике
- Внедрение зависимости, Википедия
- Связность (программирование), Википедия
Контекст и рендеринг в Реакте
Прочее
- Architecture unit test framework for Typescript
- Generics in TypeScript
- Абстракция, MDN
- Типобезопасность, Википедия
- CRUD, Википедия
Другие части серии
- Введение, предпосылки и ограничения
- Моделирование предметной области
- Проектирование пользовательских сценариев
- Описание UI как «адаптера» к приложению
- Создание инфраструктуры под нужды сценариев (этот пост)
- Композиция приложения в хуках
- Композиция приложения без хуков
- Внедрение cross-cutting concerns
- Расширение функциональности новой фичей
- Расцепление фич приложения
- Обзор и предварительные выводы