Чистая архитектура во фронтенде. Часть 3

  • 23 сентября, 11:41
  • 4441
  • 0

Автор: Саша Беспоясов

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

Мы «перевели» сценарий на TypeScript. Теперь надо проверить, совпадает ли реальность с нашими хотелками из интерфейсов.

Обычно — нет, не совпадает. Поэтому мы подстраиваем внешний мир под свои нужды с помощью адаптеров.

Связываем UI и юзкейс

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

// ui/components/Buy.tsx

export function Buy() {  // Получаем доступ к юзкейсу в компоненте:  const { orderProducts } = useOrderProducts();   async function handleSubmit(e: React.FormEvent) {    setLoading(true);    e.preventDefault();     // Вызываем функцию юзкейса:    await orderProducts(user!, cart);    setLoading(false);  }   return (    <section>      <h2>Оформить заказ</h2>      <form onSubmit={handleSubmit}>{/* ... */}</form>    </section>  ); }

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

// application/orderProducts.ts

export function useOrderProducts() {  const notifier = useNotifier();  const payment = usePayment();  const orderStorage = useOrdersStorage();   async function orderProducts(user: User, cookies: Cookie[]) {    // …  }   return { orderProducts }; }

Мы, можно сказать, используем хуки, как «кустарное внедрение зависимостей». Сперва с помощью хуков useNotifier, usePayment, useOrdersStorageмы получаем инстансы сервисов, а затем используем замыкание функции useOrderProducts, чтобы они были доступны внутри функции orderProducts.

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

Реализуем сервис оплаты

Юзкейс использует интерфейс PaymentService. Напишем его реализацию.

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

// services/paymentAdapter.ts

import { fakeApi } from "./api";
import { PaymentService } from "../application/ports"; 
export function usePayment(): PaymentService {  return {    tryPay(amount: PriceCents) {      return fakeApi(true);    },  }; }

Функция fakeApi— это таймаут, который срабатывает через 450 мс, имитируя задержку ответа от сервера. Возвращает он то, что мы передадим ему как аргумент.

// services/api.ts

export function fakeApi<TResponse>(response: TResponse): Promise<TResponse> {  return new Promise((res) => setTimeout(() => res(response), 450)); }

Мы явно типизируем возвращаемое значение у usePayment. Так TypeScript проверит, что функция действительно возвращает объект, который содержит все объявленные в интерфейсе методы.

Реализуем сервис уведомлений

Пусть уведомления будут простым алёртом. Так как код расцеплен, не будет проблем переписать и этот сервис позже.

// services/notificationAdapter.ts

import { NotificationService } from "../application/ports"; 
export function useNotifier(): NotificationService { return {    notify: (message: string) => window.alert(message),  }; }

Реализуем локальное хранилище

Пусть хранилищем будет React.Context и хуки потому что я ленивый. Создадим контекст, передадим в провайдер значение и экспортнём провайдер и доступ к стору через хуки.

// store.tsx

const StoreContext = React.createContext({});
export const useStore = () => useContext(StoreContext); 
export const Provider: React.FC = ({ children }) => {  // ...Части стора для других сущностей.  const [orders, setOrders] = useState([]);   const value = {    // ...    orders,    updateOrders: setOrders,  };   return <StoreContext.Provider value={value}>{children}</StoreContext.Provider>; };

Хук для доступа ко хранилищу напишем отдельно под каждую фичу. Так мы не нарушим ISP, а сторы, как минимум в терминах интерфейсов, будут атомарными.

// services/storageAdapter.ts

export function useOrdersStorage(): OrdersStorageService {  return useStore(); }

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

Валидируем схему потоков данных

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

Диаграмма потоков данных сценария
Диаграмма потоков данных сценария

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

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

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

Такая схема делает код заменяемым, тестируемым и расширяемым под меняющиеся требования.

Что можно улучшить

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

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

Использовать для цены объект вместо числа

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

// shared-kernel.d.ts

type PriceCents = number;

Число указывает лишь количество, но не указывает валюту, а цена без валюты смысла не имеет. По-хорошему, цену стоит сделать объектом с двумя полями: значением и валютой.

type Currency = "RUB" | "USD" | "EUR" | "SEK";
type AmountCents = number; 
type Price = {  value: AmountCents;  currency: Currency; };

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

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

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

Разделить код по фичам, а не слоям

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

Компонент — это кусок гексагонального пирога
Компонент — это кусок гексагонального пирога

О том, как делить код на подобные компоненты, советую прочесть в “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together” — там объясняется и польза, и издержки деления. А ещё советую посмотреть на Feature Sliced, который концептуально очень похож на компонентное деление кода, но проще для понимания.

Обратить внимание на кросс-компонентное использование

Если мы заговорили о делении на компоненты, стоит упомянуть и кросс-компонентное использование кода. Вспомним функцию создания заказа:

import { Product, totalPrice } from "./product"; 
export function createOrder(user: User, cart: Cart): Order {  return {    cart,    user: user.id,   status: "new",    created: new Date().toISOString(),    total: totalPrice(products),  }; }

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

Способы обойти это ограничение также можно подсмотреть в “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together” и Feature Sliced.

Использовать Branded Types, а не алиасы

Для shared kernel я использовал тип-алиасы. Они хороши тем, что ими просто оперировать: достаточно создать новый тип и сослаться например, на строку. Но их минус в том, что в TypeScript нет механизма следить за их использованием и энфорсить его.

Кажется, что это не проблема: ну использует кто-то stringвместо DateTimeString— что с того? код же соберётся.

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

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

Есть способ заставить TypeScript понимать, что мы хотим конкретный тип — использовать брендирование, branded types. Брендирование даёт возможность следить за тем, как именно типы используются, но делает код чуть более сложным.

Обратить внимание на возможную «зависимость» в домене

Следующий момент, который режет глаз — это создание даты в домене в функции createOrder:

import { Product, totalPrice } from "./product"; 
export function createOrder(user: User, cart: Cart): Order {  return {    cart,    user: user.id,     // Вот эта строка:    created: new Date().toISOString(),     status: "new",    total: totalPrice(products),  }; }

Есть подозрение, что new Date().toISOString()будет довольно часто повторяться в проекте и хочется вынести это в «хелпер»:

// lib/datetime.ts

export function currentDatetime(): DateTimeString {  return new Date().toISOString(); }

...А потом использовать в домене:

// domain/order.ts

import { currentDatetime } from "../lib/datetime";
import { Product, totalPrice } from "./product"; 
export function createOrder(user: User, cart: Cart): Order {  return {    cart,    user: user.id,    status: "new",    created: currentDatetime(),    total: totalPrice(products),  }; }

Но мы тут же вспомним, что в домене зависеть ни от чего нельзя — как же быть? По-хорошему, createOrderдолжна принимать данные для заказа в уже готовом виде, дату можно передать последним аргументом:

// domain/order.ts

export function createOrder(user: User, cart: Cart, created: DateTimeString): Order {  return {    cart,    user: user.id,    status: "new",    created,    total: totalPrice(products),  };}

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

function someUserCase() {  // Используем адаптер `dateTimeSource`,  // чтобы получить текущую дату в нужном формате:  const createdOn = dateTimeSource.currentDatetime();   // Передаём уже созданную дату в доменную функцию:  createOrder(user, cart, createdOn); }

Таким образом домен останется независимым, и к тому же его будет проще тестировать.

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

Держать доменные сущности и преобразования чистыми

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

Создание даты — это сайд-эффект, потому что в разное время результат вызова Date.now()разный. Чистая же функция при одинаковых аргументах всегда возвращает одинаковый результат.

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

Обратить внимание на отношение между корзиной и заказом

В этом маленьком примере Orderвключает в себя корзину, потому что корзина представляет только список продуктов:

export type Cart = {  products: Product[]; }; 
export type Order = {  user: UniqueId;  cart: Cart;  created: DateTimeString;  status: OrderStatus;  total: PriceCents; };

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

Как вариант, можно использовать сущность «Списка продуктов»:

type ProductList = Product[]; 
type Cart = {  products: ProductList; }; 
type Order = {  user: UniqueId;  products: ProductList;  created: DateTimeString;  status: OrderStatus;  total: PriceCents; };

Сделать юзкейс более тестируемым

В юзкейсе тоже есть что обсудить. Сейчас функцию orderProductsсложно протестировать в отрыве от React — это плохо. В идеале его должно быть можно протестировать минимальным количеством усилий.

Проблема текущей реализации в хуке, который предоставляет доступ к юзкейсу в UI:

// application/orderProducts.ts

export function useOrderProducts() {  const notifier = useNotifier();  const payment = usePayment();  const orderStorage = useOrdersStorage();  const cartStorage = useCartStorage();   async function orderProducts(user: User, cart: Cart) {    const order = createOrder(user, cart);     const paid = await payment.tryPay(order.total);    if (!paid) return notifier.notify("Оплата не прошла ");     const { orders } = orderStorage;    orderStorage.updateOrders([...orders, order]);    cartStorage.emptyCart();  }   return { orderProducts }; }

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

type Dependencies = {  notifier?: NotificationService;  payment?: PaymentService;  orderStorage?: OrderStorageService; }; 
async function orderProducts(  user: User,  cart: Cart,  dependencies: Dependencies = defaultDependencies,
) {  const { notifier, payment, orderStorage } = dependencies;   // ...
}

Хук в этом случае превратился бы в адаптер:

function useOrderProducts() {  const notifier = useNotifier();  const payment = usePayment();  const orderStorage = useOrdersStorage();   return (user: User, cart: Cart) =>    orderProducts(user, cart, {      notifier,      payment,      orderStorage,    }); }

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

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

Настроить автоматическое внедрение зависимостей

Там же в прикладном слое мы сейчас «внедряем» сервисы руками:

export function useOrderProducts() {  // Здесь мы используем хуки, чтобы получить инстансы каждого сервиса,  // который будем использоовать внутри юзкейса orderProducts:  const notifier = useNotifier();  const payment = usePayment();  const orderStorage = useOrdersStorage();  const cartStorage = useCartStorage();   async function orderProducts(user: User, cart: Cart) {    // ...Внутри юзкейса используем сервисы.  }   return { orderProducts }; }

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

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

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

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

Ветвлящаяся бизнес-логика

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

Должна ли быть «базовая» сущность, которую будут расширять? Как именно расширять эту сущность? Должны ли быть дополнительные поля? Надо ли делать эти сущности взаимоисключающими? Как должны себя вести юзкейсы, если вместо простого товара там будет другой? Надо ли сразу уменьшать дублирование?

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

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

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

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

Если всё же приходится что-то расширять...

Помните о ковариантности, контравариантности и инвариантности, чтобы случайно не придумать себе больше работы, чем следовало бы.

Используйте аналогию с блоками и модификаторами из БЭМ при выборе между разными сущностями и расширениями. Мне очень помогает определить, отдельная передо мной сущность или «модификатор-расширение», если я думаю о ней в контексте БЭМ.

Взаимозависимые сценарии

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

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

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

Итого

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

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


0 комментариев
Сортировка:
Добавить комментарий

IT Новости

Смотреть все