Гексагональная архитектура и 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/


Интересные статьи

Интересные статьи

Разработчик, привет!В этом статье я хочу поделиться примером модульного андроид приложения с помощью NavComponent (JetPack) и Koin (DI).У нас в компании есть много разных андроид проектов...
Если спросить обычного человека, как он себе представляет сервер, в большинстве случаев ответ будет содержать слова «большой компьютер». Да и близкие к теме люди привыкли, что в большин...
Всем привет.Есть такие люди, которые работают с облачной инфраструктурой и не используют автоматизацию, потому что это долго, нужно вникать, а им надо фичи пилить. Наклик...
Наша компания PVS-Studio активно публикует статьи на тему разработки, поиска ошибок в коде, техниках по улучшению его качества. Этот пост отличается по тематике – в этот раз рассмотрим вопрос пер...
Данной статьей я начну публикацию решений отправленных на дорешивание машин с площадки HackTheBox. Надеюсь, что это поможет хоть кому-то развиваться в области ИБ. В данной статье разберемся с л...