О разделении ответственности, api и хранении состояния

Есть в SOLID такой принцип «единственной ответственности», который скрывается под первой же буквой S. Он подразумевает, что модуль кода должен отвечать только за какую-то одну задачу. К примеру только отправлять оповещение или только считать квадратный корень от переданных аргументов. Естественно, чем крупнее модуль, тем шире эта самая зона ответственности, но она всё равно не должна выходить за определённые логические рамки.

И сегодня я хочу поговорить о сторах на фронтенде.

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

Как оно обычно делается в современном фронтенде?

Есть некий модуль project/api, который отвечает за запросы к серверу и эти запросы отправляются напрямую из стора и сразу там же кэшируются. Это хорошая, правда хорошая стратегия при использовании TanStack, но задача TanStack не в том, чтобы хранить клиентское состояние, а в том, чтобы кэшировать серверное состояние — это разные задачи.

Бесспорно есть метод построяния фронта, при котором всё состояние строится от серверного, и тогда выделенный store не нужен в принципе и TanStack покрывает все потребности. Однако, если нам нужно хранить состояние конкретного модуля, нередко можно увидеть запрос, улетающий прямо из pinia.

Почему это плохо?

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

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

Второй случай имеет смысл, если источников данных у нас больше двух, к примеру REST и кэш IDB.

И выглядеть это будет примерно так: └── 📁 project └── 📁 module ├── 📄 repository.ts ├── 📄 const.ts ├── 📄 index.ts ├── 📄 store.ts ├── 📄 types.ts └── 📄 useModule.ts

Поясню эту структуру.

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

store.ts — это состояние модуля, которое хранится в куче и к которому мы имеем быстрый доступ.

repository.ts — это вся логика лучения данных их любых источников: REST, Graph, WS, IDB — не важно. Бизнес-логика в норме не должна знать, откуда к нам пришли данные.

useModule.ts — это бизнес-логика. Она забирает данные из repository.ts и, в случае необходимости, убирает их в store.

Опционально можно в этой схеме переместить всю логику кэширования в repository. Тогда store становится по сути ещё одним источником данных для репозитория. Это усложняет логику репозитория, но не критично. И, главное, это находится в зоне ответственности репозитория.

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

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

Если будет интерес, постараюсь таких заметок побольше делать.