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

  • 23 сентября, 10:49
  • 3767
  • 0

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

Проектируем приложение

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

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

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

Главная страница магазина
Главная страница магазина

После удачного логина мы сможем положить какие-то печеньки к себе в корзину.

Корзина с выбранными печеньками
Корзина с выбранными печеньками

Когда мы накидали печенек в корзину, можем оформить заказ. После оплаты получаем новый заказ в списке и очищенную корзину.

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

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

Проектируем домен

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

К домену можно отнести:

  1. типы данных каждой сущности: пользователь, печенька, корзина и заказ;
  2. фабрики для создания каждой сущности, или классы, если пишем в ООП;
  3. и функции преобразования этих данных.

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

  1. функция подсчёта итоговой стоимости;
  2. определение вкусовых предпочтений пользователя
  3. определение, находится ли товар в корзине и т. д.
Диаграмма доменных сущностей во внутреннем слое
Диаграмма доменных сущностей во внутреннем слое

Проектируем прикладной слой

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

Мы, например, можем выделить:

  1. сценарий покупки товара;
  2. оплату, вызов сторонних платёжных систем;
  3. взаимодействие с товарами и заказами: обновление, просмотр;
  4. доступ ко страницам в зависимости от ролей.

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

  1. получить товары из корзины и создать новый заказ;
  2. оплатить заказ;
  3. уведомить пользователя, если оплата не прошла;
  4. очистить корзину и показать заказ.

Функция-юзкейс будет кодом, который описывает этот сценарий.

Также в прикладном слое находятся интерфейсы портов для общения с внешним миром.

Диаграмма юзкейсов и портов в среднем слое
Диаграмма юзкейсов и портов в среднем слое

Проектируем слой адаптеров

В слое адаптеров мы держим адаптеры ко внешним сервисам. Задача адаптеров — сделать несовместимое API сторонних сервисов совместимым с нашими хотелками.

Во фронтенде чаще всего это UI-фреймворк и модуль запросов к API-серверу. В нашем случае среди адаптеров мы выделим:

  1. UI-фреймворк;
  2. модуль запросов к API;
  3. адаптер для локального хранилища;
  4. адаптеры и конвертеры ответов API к прикладному слою.
Диаграмма адаптеров с разделение на управляющие и управляемые
Диаграмма адаптеров с разделение на управляющие и управляемые

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

Используем аналогию с MVC

Иногда бывает сложно сходу определиться, к какому слою отнести какой-то модуль или данные. Здесь может помочь небольшая (и неполная!) аналогия с MVC:

  1. модель — это обычно доменные сущности,
  2. контроллеры — это доменные преобразования и прикладной слой,
  3. представление — это управляющие адаптеры.

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

Детализируем дизайн: домен

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

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

src/|_domain/  |_user.ts  |_product.ts  |_order.ts  |_cart.ts |_application/  |_addToCart.ts  |_authenticate.ts |_orderProducts.ts |_ports.ts |_services/  |_authAdapter.ts  |_notificationAdapter.ts  |_paymentAdapter.ts  |_storageAdapter.ts  |_api.ts  |_store.tsx |_lib/ |_ui/

Домен находится в domain/, прикладной слой — в application/, адаптеры — в services/. Об альтернативах такому разделению кода я расскажу в конце.

Пишем доменные сущности

В домене у нас будет 4 модуля:

  1. продукт;
  2. пользователь;
  3. заказ;
  4. корзина.

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

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

// domain/user.ts

export type UserName = string;
export type User = {  id: UniqueId;  name: UserName;  email: Email;  preferences: Ingredient[];  allergies: Ingredient[]; };

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

// domain/product.ts

export type ProductTitle = string;
export type Product = {  id: UniqueId;  title: ProductTitle;  price: PriceCents;  toppings: Ingredient[]; };

В корзине мы будем лишь держать список продуктов, которые пользователь положил в неё:

// domain/cart.ts

import { Product } from "./product"; 
export type Cart = {  products: Product[]; };

После успешной оплаты создаётся заказ с указанными печеньками, создадим сущность заказа.

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

// domain/order.ts

export type OrderStatus = "new" | "delivery" | "completed"; 
export type Order = {  user: UniqueId;  cart: Cart;  created: DateTimeString;  status: OrderStatus;  total: PriceCents; };

Проверяем отношения между сущностями

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

Диаграмма отношений сущностей
Диаграмма отношений сущностей

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

Также уже на этом этапе типы помогут подсветить ошибки с совместимостью сущностей друг с другом и направлением сигналов между ними.

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

Создаём преобразования данных

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

Например, чтобы определить, есть ли у пользователя аллергия на какой-то ингредиент или предпочтение, мы можем написать функции hasAllergyи hasPreference:

// domain/user.ts

export function hasAllergy(user: User, ingredient: Ingredient): boolean {  return user.allergies.includes(ingredient); }
export function hasPreference(user: User, ingredient: Ingredient): boolean {  return user.preferences.includes(ingredient); }

Для добавления товаров в корзину и проверки, есть ли товар в корзине — функции addProductи contains:

// domain/cart.ts

export function addProduct(cart: Cart, product: Product): Cart {  return { ...cart, products: [...cart.products, product] }; }

export function contains(cart: Cart, product: Product): boolean {  return cart.products.some(({ id }) => id === product.id); }

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

// domain/product.ts

export function totalPrice(products: Product[]): PriceCents {  return products.reduce((total, { price }) => total + price, 0); }

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

// domain/order.ts

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

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

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

Детализируем дизайн: Shared Kernel

Вы могли обратить внимание на некоторые типы, которые мы использовали при описании доменных типов. Например, Email, UniqueIdили DateTimeString. Это тип-алиасы:

// shared-kernel.d.ts

type Email = string;
type UniqueId = string;
type DateTimeString = string;
type PriceCents = number;

Обычно я использую тип-алиасы, чтобы избавиться от одержимости элементарными типами (primitive obsession).

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

Указанные типы находятся в файле shared-kernel.d.ts. Shared kernel — это такой код и данные, зависимость от которых не повышает зацепление (coupling) между модулями. Подробнее о понятии советую прочесть в “DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together”.

На практике shared kernel можно объяснить так. Мы используем сам TypeScript, используем его стандартную библиотеку типов, но не считаем это зависимостями. Всё потому, что модули, которые его используют, могут ничего не знать друг о друге и оставаться расцепленными.

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

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

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

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

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

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

Используем нечистый контекст для чистых преобразований

Нечистый контекст для чистых преобразований — это такая организация кода, в которой:

  1. мы сперва производим сайд-эффект, чтобы получить данные;
  2. затем производим чистое преобразование над этими данными;
  3. а после снова производим сайд-эффект, чтобы сохранить или передать результат.

На примере сценария «Положить товар в корзину» это бы выглядело так:

  1. сперва обработчик получит данные о состоянии корзины из хранилища;
  2. затем вызовет функцию обновления корзины, передав товар, который надо добавить;
  3. после сохранит обновлённую корзину в хранилище.

Получается такой «сендвич»: сайд-эффект, чистая функция, сайд-эффект. Главная логика отражена в преобразовании данных, а всё общение с миром обособлено в императивной оболочке.

Функциональная архитектура: сайд-эффект, чистая функция, сайд-эффект
Функциональная архитектура: сайд-эффект, чистая функция, сайд-эффект

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

Проектируем сценарий

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

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

  1. мы хотим создать новый заказ;
  2. оплатить его в сторонней платёжной системе;
  3. если оплата не прошла, сообщить об этом пользователю;
  4. если прошла, сохранить заказ на сервере;
  5. добавить заказ в локальное хранилище данных, чтобы показать на экране.

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

type OrderProducts = (user: User, cart: Cart) => Promise<void>;

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

Пишем порты прикладного слоя

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

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

Порты должны быть в первую очередь удобны нашему приложению. Если API внешних сервисов несовместимо с нашими хотелками, мы напишем адаптер.

Прикинем, какие именно сервисы нам понадобятся:

  1. сервис для оплаты заказов;
  2. для уведомления пользователя о событиях и ошибках;
  3. для сохранения данных в локальное хранилище.
Необходимые сервисы для работы сценария
Необходимые сервисы для работы сценария

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

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

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

Объявляем интерфейс платёжной системы

Магазин — это приложение-пример, поэтому платёжная система будет предельно простой. У неё будет метод tryPay, который будет принимать количество денег, которое надо заплатить, а в ответ будет присылать подтверждение, что всё нормально.

// application/ports.ts

export interface PaymentService {  tryPay(amount: PriceCents): Promise<boolean>; }

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

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

Объявляем интерфейс сервиса уведомлений

Если что-то при оплате пойдёт не по плану, об этом надо сказать.

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

Пусть принимает сообщение и как-то уведомляет пользователя:

// application/ports.ts

export interface NotificationService {  notify(message: string): void; }

Объявляем интерфейс локального хранилища

Сохранять новый заказ будем в локальном хранилище.

Этим хранилищем может быть что угодно: Redux, MobX, whatever-floats-your-boat-js. Хранилище может быть разделено на микро-сторы для разных сущностей или быть одним большим для всех данных приложения — сейчас это тоже не важно, потому что это детали реализации. Нам же важно спроектировать интерфейс.

Я люблю интерфейсы хранилищ делить на отдельные под каждую сущность. Отдельный интерфейс для хранилища данных о пользователе, отдельный — для корзины, отдельный — для хранилища заказов:

// application/ports.ts

export interface OrdersStorageService {  orders: Order[];  updateOrders(orders: Order[]): void; }

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

Пишем код сценария

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

  1. проверяем данные;
  2. создаём заказ;
  3. оплачиваем заказ;
  4. уведомляем о проблемах;
  5. сохраняем результат.
Все шаги пользовательского сценария на схеме
Все шаги пользовательского сценария на схеме

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

// application/orderProducts.ts

const payment: PaymentService = {};
const notifier: NotificationService = {};
const orderStorage: OrdersStorageService = {};

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

Создадим функцию-юзкейс orderProducts. Внутри первым делом создаём новый заказ:

// application/orderProducts.ts
//...

async function orderProducts(user: User, cart: Cart) {  const order = createOrder(user, cart); }

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

// application/orderProducts.ts
//...

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(); }

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



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

IT Новости

Смотреть все