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

в 8:56, , рубрики: DDD, domain driven, domain driven architecture, domain driven development, domain-driven design, geksagon architecture, TypeScript, архитектура, интерфейсы, конференции, ооп, Программирование
Гексагональная архитектура и Domain Driven Design на примере Front-end приложения - 1

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

Погружение

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

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

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

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

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

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

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

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

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

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

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

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

Минусы

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

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


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

Порты

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

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

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

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

Адаптеры

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

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

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


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

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

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

Структура

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

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

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

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 { ProductListEntity } from "./ProductListEntity";
import { Either } from "@sweet-monads/either";
import { ErrorEntity } from "../ErrorEntity";
import { ProductId } from "./ProductEntity";

export interface ProductLoadQuery {
  load(ids: ProductId[]): Either<ErrorEntity, ProductListEntity>;
}

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

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://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js