Гексагональная архитектура и Domain Driven Design на примере Front-end приложения

от автора

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

Погружение

Domain Driven Design — это набор принципов и схем, направленных на создание оптимальных систем объектов. Он сводится к созданию программных абстракций, которые называются моделями предметных областей. В эти модели входит бизнес-логика, устанавливающая связь между реальными условиями области применения продукта и кодом.

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

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

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

Основа данной архитектуры — порты и адаптеры.

Порты — это интерфейсы нашего приложения,

Адаптеры —  реализация наших портов.

Гексагон — фигура, имеющая 6 сторон, шестиугольник. В нашем случае слоистая или многогранная архитектура.

Преимущества данного метода:

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

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

  1. Легкая изменчивость: изменения в одной области нашего приложения не влияют на другие области.

Минусы

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

Также могут возникнуть сложности реализации с graphql.


Как это работает на практике?

Порты

Порты могут быть первичными (входящими) primary и вторичными (исходящими) secondary —  это связи между внешним миром и ядром приложения. 

Первичные  порты — это запросы поступающие в приложение http, api, подключение к бд. 

Вторичные порты используются ядром приложения для доступа к внешним службам.

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

Адаптеры

Адаптеры служат реализацией наших портов. Есть два типа адаптеров: первичный и вторичный — по аналогии с портами.

К примеру, адаптеры, взаимодействующие с веб-браузером реализуют вторичный порт, а те адаптеры, которые устанавливают связь с внешним миром (api), реализуют первичный порт.

Порты позволяют нам подключать адаптеры к основному домену.


Организация в проекте

INFRASTRUCTURE — это бизнес-логика

Adapter — реализует первичный primary (pr) порт, связывает внешний мир с доменом

Services — реализует вторичный secondary (sec), адаптер связывает приложение с доменом (в сервисах можно работать с браузерным api)

Schema — используется для валидации данных, пришедших от INFRASTRUCTURE. В последующем используется в DTO для преобразования в Entities

Commands — входные данные для адаптеров

Controller — Зависят от фреймворка. Это то, что вызывает сервис, например, в случае vuex или redux будет actions


Переходим к коду

Пример для ознакомления https://github.com/jtapes/geksagon-architecture-domain-driven-design

Структура

Для начала создадим сущности в нашем домене.

Создадим продукт нашего магазина:

export type ProductId = string; export type ProductName = string; export type ProductPrice = number;  export class ProductEntity {   constructor(     private readonly _id: ProductId,     private readonly _name: ProductName,     private readonly _price: ProductPrice   ) {}    /* istanbul ignore next */   public get id() {     return this._id;   }    /* istanbul ignore next */   public get name() {     return this._name;   }    /* istanbul ignore next */   public get price() {     return this._price;   } } 

Создадим листинг продуктов:

import { ProductEntity } from "./ProductEntity";  export class ProductListEntity {   constructor(protected readonly _products: ProductEntity[] = []) {}    /* istanbul ignore next */   get products() {     return this._products;   }    get namesLog() {     return this._products.map((product) => product.name).join(" ");   } } 

Если используем методы или сложные геттеры и сеттеры, рекомендую писать тесты:

import { ProductListingMock } from "../../../application/mocks/ProductListingMock";  describe("Testing ProductListEntity", () => {   test("get namesLog", () => {     expect(ProductListingMock.namesLog === "snickers mars kinder").toBeTruthy();   }); }); 

пример условный.

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

import { ProductListEntity } from "../../domain/product/ProductListEntity"; import { ProductEntity } from "../../domain/product/ProductEntity";  export const ProductListingMock = new ProductListEntity([   new ProductEntity("1", "snickers", 60),   new ProductEntity("2", "mars", 80),   new ProductEntity("3", "kinder", 120), ]); 

Создадим интерфейс первичного порта ProductLoadPort для получения данных извне:

import { Either } from "@sweet-monads/either"; import { ErrorEntity } from "../ErrorEntity"; import { ProductListEntity } from "./ProductListEntity"; import { ProductLoadCommand } from "./ProductLoadCommand"; export interface ProductLoadPort {   load(command: ProductLoadCommand): Either<ErrorEntity, ProductListEntity>; }

На вход принимаем команду ProductLoadCommand и отдаем ProductListEnitity в случае успеха или ErrorEntities при ошибке.

ProductLoadCommand:

import { ProductId } from "./ProductEntity";  export class ProductLoadCommand {   constructor(     private readonly _ids: ProductId[],     private readonly _lang: string = "ru"   ) {}    public get ids(): ProductId[] {     return this._ids;   }    public get lang(): ProductId {     return this._lang;   } } 

Реализуем этот порт в первичном адаптере:

import { ProductLoadPort } from "../../../domain/product/ProductLoadPort"; import { ProductLoadCommand } from "../../../domain/product/ProductLoadCommand"; import { productsMapper } from "../../mappers/ProductMapper"; import { ProductsResponseSchema } from "../../schema/ProductsSchema"; import { right, left } from "@sweet-monads/either"; import { ErrorEntity } from "../../../domain/ErrorEntity"; import { AxiosType } from "../../../types/AxiosType";  export class ProductLoadAdapter implements ProductLoadPort {   api(command: ProductLoadCommand): AxiosType {     const responseJson = process.api.products.filter((product) => {       return command.ids.includes(product.id);     });     return {       data: responseJson as unknown,       code: 200,     };   }    load(command: ProductLoadCommand) {     const response = this.api(command);     const valid = ProductsResponseSchema.safeParse(response.data);     return valid.success       ? right(productsMapper(valid.data))       : left(new ErrorEntity("productLoad", valid.error));   } } 

Метод api возвращает неизвестные для нас данные, поэтому  нужно провалидировать их по схеме:

ProductsResponseSchema

import { z } from "zod";  export const ProductsResponseSchema = z.array(   z.object({     id: z.string().max(2),     title: z.string(),     price: z.number().max(1000),   }) ); export type ProductsResponseSchemaType = z.infer<typeof ProductsResponseSchema>; 
 const valid = ProductsResponseSchema.safeParse(response.data);

если valid.success = true,  вызовем DTO (mapper) 

productMapper:

import { ProductEntity } from "../../domain/product/ProductEntity"; import { ProductListEntity } from "../../domain/product/ProductListEntity"; import { ProductsResponseSchemaType } from "../schema/ProductsSchema";  export function productsMapper(   response: ProductsResponseSchemaType ): ProductListEntity {   return new ProductListEntity(     response.map(       (product) => new ProductEntity(product.id, product.title, product.price)     )   ); }

Так как мы уже проверили, что данные из метода api соответствуют типу ProductsResponseSchemaType (valid.success = true),

в productMapper ошибок не будет.

В productMapper лишь одно изменение, поле title записываем в name.

Первичный адаптер готов!

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

Теперь на основе порта реализуем вторичный адаптер (сервис):

import { ProductLoadQuery } from "../../../domain/product/ProductLoadQuery"; import { ProductLoadCommand } from "../../../domain/product/ProductLoadCommand"; import { ProductLoadAdapter } from "../../adapters/product/ProductLoad"; import { ProductId } from "../../../domain/product/ProductEntity";  export class ProductLoadService implements ProductLoadQuery {   productLoadPort = new ProductLoadAdapter();    localization() {     // mock browser api     const navigator = {       language: "en-EN",     };     const userLang = navigator.language;     switch (userLang) {       case "ru-RU":         return "ru";       case "en-EN":         return "en";       default:         return "ru";     }   }    load(ids: ProductId[]) {     const command = new ProductLoadCommand(ids, this.localization());     return this.productLoadPort.load(command);   } } 

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

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

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


Заключение

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


ссылка на оригинал статьи https://habr.com/ru/post/654629/


Комментарии

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

Ваш адрес email не будет опубликован. Обязательные поля помечены *